feat(pro): add analytics, export, and multi-account commercial features
3 new agor-pro modules: analytics.rs (usage dashboard queries), export.rs (session/project Markdown report generation), profiles.rs (multi-account switching via accounts.json). 9 Tauri plugin commands. Frontend IPC bridge (pro-bridge.ts). 168 cargo tests, 14 commercial vitest tests.
This commit is contained in:
parent
6973c70c5a
commit
03fe2e2237
8 changed files with 805 additions and 3 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -62,7 +62,9 @@ name = "agor-pro"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agor-core",
|
"agor-core",
|
||||||
|
"dirs 5.0.1",
|
||||||
"log",
|
"log",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ license = "LicenseRef-Commercial"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
agor-core = { path = "../agor-core" }
|
agor-core = { path = "../agor-core" }
|
||||||
tauri = { version = "2.10.3", features = [] }
|
tauri = { version = "2.10.3", features = [] }
|
||||||
|
rusqlite = { version = "0.31", features = ["bundled-full"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
dirs = "5"
|
||||||
|
|
|
||||||
176
agor-pro/src/analytics.rs
Normal file
176
agor-pro/src/analytics.rs
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
// SPDX-License-Identifier: LicenseRef-Commercial
|
||||||
|
// Analytics Dashboard — historical cost tracking, model usage, token trends.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AnalyticsSummary {
|
||||||
|
pub total_sessions: i64,
|
||||||
|
pub total_cost_usd: f64,
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub total_turns: i64,
|
||||||
|
pub total_tool_calls: i64,
|
||||||
|
pub avg_cost_per_session: f64,
|
||||||
|
pub avg_tokens_per_session: f64,
|
||||||
|
pub period_days: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DailyStats {
|
||||||
|
pub date: String,
|
||||||
|
pub session_count: i64,
|
||||||
|
pub cost_usd: f64,
|
||||||
|
pub tokens: i64,
|
||||||
|
pub turns: i64,
|
||||||
|
pub tool_calls: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ModelBreakdown {
|
||||||
|
pub model: String,
|
||||||
|
pub session_count: i64,
|
||||||
|
pub total_cost_usd: f64,
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub avg_cost_per_session: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn pro_analytics_summary(project_id: String, days: Option<i64>) -> Result<AnalyticsSummary, String> {
|
||||||
|
let conn = super::open_sessions_db()?;
|
||||||
|
let period = days.unwrap_or(30);
|
||||||
|
let cutoff = now_epoch() - (period * 86400);
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT COUNT(*), COALESCE(SUM(cost_usd), 0), COALESCE(SUM(peak_tokens), 0),
|
||||||
|
COALESCE(SUM(turn_count), 0), COALESCE(SUM(tool_call_count), 0)
|
||||||
|
FROM session_metrics WHERE project_id = ?1 AND end_time >= ?2"
|
||||||
|
).map_err(|e| format!("Query failed: {e}"))?;
|
||||||
|
|
||||||
|
let result = stmt.query_row(rusqlite::params![project_id, cutoff], |row| {
|
||||||
|
let count: i64 = row.get(0)?;
|
||||||
|
let cost: f64 = row.get(1)?;
|
||||||
|
let tokens: i64 = row.get(2)?;
|
||||||
|
let turns: i64 = row.get(3)?;
|
||||||
|
let tools: i64 = row.get(4)?;
|
||||||
|
Ok(AnalyticsSummary {
|
||||||
|
total_sessions: count,
|
||||||
|
total_cost_usd: cost,
|
||||||
|
total_tokens: tokens,
|
||||||
|
total_turns: turns,
|
||||||
|
total_tool_calls: tools,
|
||||||
|
avg_cost_per_session: if count > 0 { cost / count as f64 } else { 0.0 },
|
||||||
|
avg_tokens_per_session: if count > 0 { tokens as f64 / count as f64 } else { 0.0 },
|
||||||
|
period_days: period,
|
||||||
|
})
|
||||||
|
}).map_err(|e| format!("Query failed: {e}"))?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn pro_analytics_daily(project_id: String, days: Option<i64>) -> Result<Vec<DailyStats>, String> {
|
||||||
|
let conn = super::open_sessions_db()?;
|
||||||
|
let period = days.unwrap_or(30);
|
||||||
|
let cutoff = now_epoch() - (period * 86400);
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT date(end_time, 'unixepoch') as day,
|
||||||
|
COUNT(*), COALESCE(SUM(cost_usd), 0), COALESCE(SUM(peak_tokens), 0),
|
||||||
|
COALESCE(SUM(turn_count), 0), COALESCE(SUM(tool_call_count), 0)
|
||||||
|
FROM session_metrics
|
||||||
|
WHERE project_id = ?1 AND end_time >= ?2
|
||||||
|
GROUP BY day ORDER BY day ASC"
|
||||||
|
).map_err(|e| format!("Query failed: {e}"))?;
|
||||||
|
|
||||||
|
let rows = stmt.query_map(rusqlite::params![project_id, cutoff], |row| {
|
||||||
|
Ok(DailyStats {
|
||||||
|
date: row.get(0)?,
|
||||||
|
session_count: row.get(1)?,
|
||||||
|
cost_usd: row.get(2)?,
|
||||||
|
tokens: row.get(3)?,
|
||||||
|
turns: row.get(4)?,
|
||||||
|
tool_calls: row.get(5)?,
|
||||||
|
})
|
||||||
|
}).map_err(|e| format!("Query failed: {e}"))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| format!("Row read failed: {e}"))?;
|
||||||
|
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn pro_analytics_model_breakdown(project_id: String, days: Option<i64>) -> Result<Vec<ModelBreakdown>, String> {
|
||||||
|
let conn = super::open_sessions_db()?;
|
||||||
|
let period = days.unwrap_or(30);
|
||||||
|
let cutoff = now_epoch() - (period * 86400);
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT COALESCE(model, 'unknown'), COUNT(*), COALESCE(SUM(cost_usd), 0),
|
||||||
|
COALESCE(SUM(peak_tokens), 0)
|
||||||
|
FROM session_metrics
|
||||||
|
WHERE project_id = ?1 AND end_time >= ?2
|
||||||
|
GROUP BY model ORDER BY SUM(cost_usd) DESC"
|
||||||
|
).map_err(|e| format!("Query failed: {e}"))?;
|
||||||
|
|
||||||
|
let rows = stmt.query_map(rusqlite::params![project_id, cutoff], |row| {
|
||||||
|
let count: i64 = row.get(1)?;
|
||||||
|
let cost: f64 = row.get(2)?;
|
||||||
|
Ok(ModelBreakdown {
|
||||||
|
model: row.get(0)?,
|
||||||
|
session_count: count,
|
||||||
|
total_cost_usd: cost,
|
||||||
|
total_tokens: row.get(3)?,
|
||||||
|
avg_cost_per_session: if count > 0 { cost / count as f64 } else { 0.0 },
|
||||||
|
})
|
||||||
|
}).map_err(|e| format!("Query failed: {e}"))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| format!("Row read failed: {e}"))?;
|
||||||
|
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn now_epoch() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_analytics_summary_struct() {
|
||||||
|
let s = AnalyticsSummary {
|
||||||
|
total_sessions: 5,
|
||||||
|
total_cost_usd: 1.25,
|
||||||
|
total_tokens: 50000,
|
||||||
|
total_turns: 30,
|
||||||
|
total_tool_calls: 100,
|
||||||
|
avg_cost_per_session: 0.25,
|
||||||
|
avg_tokens_per_session: 10000.0,
|
||||||
|
period_days: 30,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&s).unwrap();
|
||||||
|
assert!(json.contains("totalSessions"));
|
||||||
|
assert!(json.contains("avgCostPerSession"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_model_breakdown_serializes_camel_case() {
|
||||||
|
let m = ModelBreakdown {
|
||||||
|
model: "opus".into(),
|
||||||
|
session_count: 3,
|
||||||
|
total_cost_usd: 0.75,
|
||||||
|
total_tokens: 30000,
|
||||||
|
avg_cost_per_session: 0.25,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&m).unwrap();
|
||||||
|
assert!(json.contains("sessionCount"));
|
||||||
|
assert!(json.contains("totalCostUsd"));
|
||||||
|
}
|
||||||
|
}
|
||||||
241
agor-pro/src/export.rs
Normal file
241
agor-pro/src/export.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
// SPDX-License-Identifier: LicenseRef-Commercial
|
||||||
|
// Session Export & Reporting — generate Markdown reports from agent sessions.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionReport {
|
||||||
|
pub project_id: String,
|
||||||
|
pub session_id: String,
|
||||||
|
pub markdown: String,
|
||||||
|
pub cost_usd: f64,
|
||||||
|
pub turn_count: i64,
|
||||||
|
pub tool_call_count: i64,
|
||||||
|
pub duration_minutes: f64,
|
||||||
|
pub model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProjectSummaryReport {
|
||||||
|
pub project_id: String,
|
||||||
|
pub markdown: String,
|
||||||
|
pub total_sessions: i64,
|
||||||
|
pub total_cost_usd: f64,
|
||||||
|
pub period_days: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn pro_export_session(project_id: String, session_id: String) -> Result<SessionReport, String> {
|
||||||
|
let conn = super::open_sessions_db()?;
|
||||||
|
|
||||||
|
// Get metric for this session
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT start_time, end_time, peak_tokens, turn_count, tool_call_count, cost_usd, model, status, error_message
|
||||||
|
FROM session_metrics WHERE project_id = ?1 AND session_id = ?2"
|
||||||
|
).map_err(|e| format!("Query failed: {e}"))?;
|
||||||
|
|
||||||
|
let (start, end, tokens, turns, tools, cost, model, status, error): (i64, i64, i64, i64, i64, f64, Option<String>, String, Option<String>) =
|
||||||
|
stmt.query_row(rusqlite::params![project_id, session_id], |row| {
|
||||||
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?,
|
||||||
|
row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?, row.get(8)?))
|
||||||
|
}).map_err(|e| format!("Session not found: {e}"))?;
|
||||||
|
|
||||||
|
let model_name = model.clone().unwrap_or_else(|| "unknown".into());
|
||||||
|
let duration_min = (end - start) as f64 / 60.0;
|
||||||
|
let start_str = epoch_to_iso(start);
|
||||||
|
let end_str = epoch_to_iso(end);
|
||||||
|
|
||||||
|
// Get messages for this session
|
||||||
|
let mut msg_stmt = conn.prepare(
|
||||||
|
"SELECT message_type, content, created_at
|
||||||
|
FROM agent_messages WHERE session_id = ?1 ORDER BY created_at ASC"
|
||||||
|
).map_err(|e| format!("Messages query failed: {e}"))?;
|
||||||
|
|
||||||
|
let messages: Vec<(String, String, i64)> = msg_stmt
|
||||||
|
.query_map(rusqlite::params![session_id], |row| {
|
||||||
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("Messages query failed: {e}"))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| format!("Row read failed: {e}"))?;
|
||||||
|
|
||||||
|
let mut md = String::new();
|
||||||
|
md.push_str(&format!("# Session Report: {session_id}\n\n"));
|
||||||
|
md.push_str(&format!("**Project:** {project_id} \n"));
|
||||||
|
md.push_str(&format!("**Model:** {model_name} \n"));
|
||||||
|
md.push_str(&format!("**Status:** {status} \n"));
|
||||||
|
md.push_str(&format!("**Duration:** {duration_min:.1} min \n"));
|
||||||
|
md.push_str(&format!("**Start:** {start_str} \n"));
|
||||||
|
md.push_str(&format!("**End:** {end_str} \n\n"));
|
||||||
|
md.push_str("## Metrics\n\n");
|
||||||
|
md.push_str(&format!("| Metric | Value |\n|--------|-------|\n"));
|
||||||
|
md.push_str(&format!("| Cost | ${cost:.4} |\n"));
|
||||||
|
md.push_str(&format!("| Peak Tokens | {tokens} |\n"));
|
||||||
|
md.push_str(&format!("| Turns | {turns} |\n"));
|
||||||
|
md.push_str(&format!("| Tool Calls | {tools} |\n\n"));
|
||||||
|
|
||||||
|
if let Some(err) = &error {
|
||||||
|
md.push_str(&format!("## Error\n\n```\n{err}\n```\n\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !messages.is_empty() {
|
||||||
|
md.push_str("## Conversation\n\n");
|
||||||
|
for (msg_type, content, ts) in &messages {
|
||||||
|
let time = epoch_to_time(*ts);
|
||||||
|
let prefix = match msg_type.as_str() {
|
||||||
|
"user" | "human" => "**User**",
|
||||||
|
"assistant" => "**Assistant**",
|
||||||
|
"tool_call" => "**Tool Call**",
|
||||||
|
"tool_result" => "**Tool Result**",
|
||||||
|
_ => msg_type.as_str(),
|
||||||
|
};
|
||||||
|
// Truncate long content
|
||||||
|
let display = if content.len() > 500 {
|
||||||
|
format!("{}... *(truncated, {} chars)*", &content[..500], content.len())
|
||||||
|
} else {
|
||||||
|
content.clone()
|
||||||
|
};
|
||||||
|
md.push_str(&format!("### [{time}] {prefix}\n\n{display}\n\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SessionReport {
|
||||||
|
project_id,
|
||||||
|
session_id,
|
||||||
|
markdown: md,
|
||||||
|
cost_usd: cost,
|
||||||
|
turn_count: turns,
|
||||||
|
tool_call_count: tools,
|
||||||
|
duration_minutes: duration_min,
|
||||||
|
model: model_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn pro_export_project_summary(project_id: String, days: Option<i64>) -> Result<ProjectSummaryReport, String> {
|
||||||
|
let conn = super::open_sessions_db()?;
|
||||||
|
let period = days.unwrap_or(30);
|
||||||
|
let cutoff = super::analytics::now_epoch() - (period * 86400);
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT session_id, start_time, end_time, cost_usd, turn_count, tool_call_count, model, status
|
||||||
|
FROM session_metrics WHERE project_id = ?1 AND end_time >= ?2 ORDER BY end_time DESC"
|
||||||
|
).map_err(|e| format!("Query failed: {e}"))?;
|
||||||
|
|
||||||
|
let sessions: Vec<(String, i64, i64, f64, i64, i64, Option<String>, String)> = stmt
|
||||||
|
.query_map(rusqlite::params![project_id, cutoff], |row| {
|
||||||
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?,
|
||||||
|
row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?))
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("Query failed: {e}"))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| format!("Row read failed: {e}"))?;
|
||||||
|
|
||||||
|
let total_cost: f64 = sessions.iter().map(|s| s.3).sum();
|
||||||
|
let total_sessions = sessions.len() as i64;
|
||||||
|
|
||||||
|
let mut md = String::new();
|
||||||
|
md.push_str(&format!("# Project Summary: {project_id}\n\n"));
|
||||||
|
md.push_str(&format!("**Period:** last {period} days \n"));
|
||||||
|
md.push_str(&format!("**Total Sessions:** {total_sessions} \n"));
|
||||||
|
md.push_str(&format!("**Total Cost:** ${total_cost:.4} \n\n"));
|
||||||
|
|
||||||
|
if !sessions.is_empty() {
|
||||||
|
md.push_str("## Sessions\n\n");
|
||||||
|
md.push_str("| Date | Session | Model | Duration | Cost | Turns | Tools | Status |\n");
|
||||||
|
md.push_str("|------|---------|-------|----------|------|-------|-------|--------|\n");
|
||||||
|
for (sid, start, end, cost, turns, tools, model, status) in &sessions {
|
||||||
|
let date = epoch_to_date(*start);
|
||||||
|
let dur = (*end - *start) as f64 / 60.0;
|
||||||
|
let m = model.as_deref().unwrap_or("?");
|
||||||
|
let short_sid = if sid.len() > 8 { &sid[..8] } else { sid };
|
||||||
|
md.push_str(&format!("| {date} | {short_sid}.. | {m} | {dur:.0}m | ${cost:.4} | {turns} | {tools} | {status} |\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ProjectSummaryReport {
|
||||||
|
project_id,
|
||||||
|
markdown: md,
|
||||||
|
total_sessions,
|
||||||
|
total_cost_usd: total_cost,
|
||||||
|
period_days: period,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn epoch_to_iso(epoch: i64) -> String {
|
||||||
|
let secs = epoch;
|
||||||
|
let days = secs / 86400;
|
||||||
|
let time = secs % 86400;
|
||||||
|
let h = time / 3600;
|
||||||
|
let m = (time % 3600) / 60;
|
||||||
|
// Simple date from epoch (2000-01-01 = day 10957 from 1970)
|
||||||
|
format!("{}T{:02}:{:02}Z", epoch_to_date(secs), h, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn epoch_to_time(epoch: i64) -> String {
|
||||||
|
let time = epoch % 86400;
|
||||||
|
format!("{:02}:{:02}", time / 3600, (time % 3600) / 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn epoch_to_date(epoch: i64) -> String {
|
||||||
|
// Days since 1970-01-01
|
||||||
|
let mut days = epoch / 86400;
|
||||||
|
let mut year = 1970i64;
|
||||||
|
loop {
|
||||||
|
let days_in_year = if is_leap(year) { 366 } else { 365 };
|
||||||
|
if days < days_in_year { break; }
|
||||||
|
days -= days_in_year;
|
||||||
|
year += 1;
|
||||||
|
}
|
||||||
|
let months = if is_leap(year) {
|
||||||
|
[31,29,31,30,31,30,31,31,30,31,30,31]
|
||||||
|
} else {
|
||||||
|
[31,28,31,30,31,30,31,31,30,31,30,31]
|
||||||
|
};
|
||||||
|
let mut month = 0usize;
|
||||||
|
for (i, &d) in months.iter().enumerate() {
|
||||||
|
if days < d { month = i; break; }
|
||||||
|
days -= d;
|
||||||
|
}
|
||||||
|
format!("{year:04}-{:02}-{:02}", month + 1, days + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_leap(y: i64) -> bool {
|
||||||
|
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_epoch_to_date() {
|
||||||
|
assert_eq!(epoch_to_date(0), "1970-01-01");
|
||||||
|
assert_eq!(epoch_to_date(1710000000), "2024-03-09");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_epoch_to_iso() {
|
||||||
|
let iso = epoch_to_iso(1710000000);
|
||||||
|
assert!(iso.starts_with("2024-"));
|
||||||
|
assert!(iso.ends_with('Z'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_report_struct() {
|
||||||
|
let r = SessionReport {
|
||||||
|
project_id: "test".into(),
|
||||||
|
session_id: "abc".into(),
|
||||||
|
markdown: "# Report".into(),
|
||||||
|
cost_usd: 0.5,
|
||||||
|
turn_count: 10,
|
||||||
|
tool_call_count: 5,
|
||||||
|
duration_minutes: 15.0,
|
||||||
|
model: "opus".into(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&r).unwrap();
|
||||||
|
assert!(json.contains("durationMinutes"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,17 +4,27 @@
|
||||||
// This crate is NOT open-source. It is distributed only via the
|
// This crate is NOT open-source. It is distributed only via the
|
||||||
// agents-orchestrator/agents-orchestrator private repository.
|
// agents-orchestrator/agents-orchestrator private repository.
|
||||||
|
|
||||||
|
mod analytics;
|
||||||
|
mod export;
|
||||||
|
mod profiles;
|
||||||
|
|
||||||
use tauri::{
|
use tauri::{
|
||||||
plugin::{Builder, TauriPlugin},
|
plugin::{Builder, TauriPlugin},
|
||||||
Runtime,
|
Runtime,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Initialize the agor-pro Tauri plugin.
|
|
||||||
/// Registers all commercial commands and managed state.
|
|
||||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
Builder::new("agor-pro")
|
Builder::new("agor-pro")
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
pro_status,
|
pro_status,
|
||||||
|
analytics::pro_analytics_summary,
|
||||||
|
analytics::pro_analytics_daily,
|
||||||
|
analytics::pro_analytics_model_breakdown,
|
||||||
|
export::pro_export_session,
|
||||||
|
export::pro_export_project_summary,
|
||||||
|
profiles::pro_list_accounts,
|
||||||
|
profiles::pro_get_active_account,
|
||||||
|
profiles::pro_set_active_account,
|
||||||
])
|
])
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
@ -24,6 +34,14 @@ fn pro_status() -> String {
|
||||||
"active".to_string()
|
"active".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open the sessions.db for the current data directory.
|
||||||
|
fn open_sessions_db() -> Result<rusqlite::Connection, String> {
|
||||||
|
let config = agor_core::config::AppConfig::from_env();
|
||||||
|
let db_path = config.data_dir.join("sessions.db");
|
||||||
|
rusqlite::Connection::open(&db_path)
|
||||||
|
.map_err(|e| format!("Failed to open sessions.db: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
143
agor-pro/src/profiles.rs
Normal file
143
agor-pro/src/profiles.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
// SPDX-License-Identifier: LicenseRef-Commercial
|
||||||
|
// Multi-Account Profile Switching — manage multiple Claude/provider accounts.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AccountProfile {
|
||||||
|
pub id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub provider: String,
|
||||||
|
pub config_dir: String,
|
||||||
|
pub is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ActiveAccount {
|
||||||
|
pub profile_id: String,
|
||||||
|
pub provider: String,
|
||||||
|
pub config_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all configured account profiles across providers.
|
||||||
|
/// Scans ~/.config/agor/accounts.json for profile definitions.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn pro_list_accounts() -> Result<Vec<AccountProfile>, String> {
|
||||||
|
let accounts_path = accounts_file_path()?;
|
||||||
|
let active = load_active_id();
|
||||||
|
|
||||||
|
if !accounts_path.exists() {
|
||||||
|
// Return default account derived from Claude profiles
|
||||||
|
let home = dirs::home_dir().unwrap_or_default();
|
||||||
|
let default_dir = home.join(".claude").to_string_lossy().to_string();
|
||||||
|
return Ok(vec![AccountProfile {
|
||||||
|
id: "default".into(),
|
||||||
|
display_name: "Default".into(),
|
||||||
|
email: None,
|
||||||
|
provider: "claude".into(),
|
||||||
|
config_dir: default_dir,
|
||||||
|
is_active: true,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&accounts_path)
|
||||||
|
.map_err(|e| format!("Failed to read accounts.json: {e}"))?;
|
||||||
|
|
||||||
|
let mut profiles: Vec<AccountProfile> = serde_json::from_str(&content)
|
||||||
|
.map_err(|e| format!("Failed to parse accounts.json: {e}"))?;
|
||||||
|
|
||||||
|
for p in &mut profiles {
|
||||||
|
p.is_active = p.id == active;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(profiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the currently active account.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn pro_get_active_account() -> Result<ActiveAccount, String> {
|
||||||
|
let profiles = pro_list_accounts()?;
|
||||||
|
let active = profiles.iter()
|
||||||
|
.find(|p| p.is_active)
|
||||||
|
.or_else(|| profiles.first())
|
||||||
|
.ok_or("No accounts configured")?;
|
||||||
|
|
||||||
|
Ok(ActiveAccount {
|
||||||
|
profile_id: active.id.clone(),
|
||||||
|
provider: active.provider.clone(),
|
||||||
|
config_dir: active.config_dir.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch the active account. Updates ~/.config/agor/active-account.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn pro_set_active_account(profile_id: String) -> Result<ActiveAccount, String> {
|
||||||
|
let profiles = pro_list_accounts()?;
|
||||||
|
let target = profiles.iter()
|
||||||
|
.find(|p| p.id == profile_id)
|
||||||
|
.ok_or_else(|| format!("Account '{}' not found", profile_id))?;
|
||||||
|
|
||||||
|
let active_path = active_account_path()?;
|
||||||
|
std::fs::write(&active_path, &profile_id)
|
||||||
|
.map_err(|e| format!("Failed to write active account: {e}"))?;
|
||||||
|
|
||||||
|
Ok(ActiveAccount {
|
||||||
|
profile_id: target.id.clone(),
|
||||||
|
provider: target.provider.clone(),
|
||||||
|
config_dir: target.config_dir.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accounts_file_path() -> Result<PathBuf, String> {
|
||||||
|
let config = agor_core::config::AppConfig::from_env();
|
||||||
|
Ok(config.config_dir.join("accounts.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_account_path() -> Result<PathBuf, String> {
|
||||||
|
let config = agor_core::config::AppConfig::from_env();
|
||||||
|
Ok(config.config_dir.join("active-account"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_active_id() -> String {
|
||||||
|
active_account_path()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|| "default".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_account_profile_serializes_camel_case() {
|
||||||
|
let p = AccountProfile {
|
||||||
|
id: "work".into(),
|
||||||
|
display_name: "Work Account".into(),
|
||||||
|
email: Some("work@example.com".into()),
|
||||||
|
provider: "claude".into(),
|
||||||
|
config_dir: "/home/user/.claude-work".into(),
|
||||||
|
is_active: true,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&p).unwrap();
|
||||||
|
assert!(json.contains("displayName"));
|
||||||
|
assert!(json.contains("isActive"));
|
||||||
|
assert!(json.contains("configDir"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_active_account_struct() {
|
||||||
|
let a = ActiveAccount {
|
||||||
|
profile_id: "test".into(),
|
||||||
|
provider: "claude".into(),
|
||||||
|
config_dir: "/tmp/test".into(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&a).unwrap();
|
||||||
|
assert!(json.contains("profileId"));
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/lib/commercial/pro-bridge.ts
Normal file
99
src/lib/commercial/pro-bridge.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
// SPDX-License-Identifier: LicenseRef-Commercial
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
// --- Analytics ---
|
||||||
|
|
||||||
|
export interface AnalyticsSummary {
|
||||||
|
totalSessions: number;
|
||||||
|
totalCostUsd: number;
|
||||||
|
totalTokens: number;
|
||||||
|
totalTurns: number;
|
||||||
|
totalToolCalls: number;
|
||||||
|
avgCostPerSession: number;
|
||||||
|
avgTokensPerSession: number;
|
||||||
|
periodDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyStats {
|
||||||
|
date: string;
|
||||||
|
sessionCount: number;
|
||||||
|
costUsd: number;
|
||||||
|
tokens: number;
|
||||||
|
turns: number;
|
||||||
|
toolCalls: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelBreakdown {
|
||||||
|
model: string;
|
||||||
|
sessionCount: number;
|
||||||
|
totalCostUsd: number;
|
||||||
|
totalTokens: number;
|
||||||
|
avgCostPerSession: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const proAnalyticsSummary = (projectId: string, days?: number) =>
|
||||||
|
invoke<AnalyticsSummary>('plugin:agor-pro|pro_analytics_summary', { projectId, days });
|
||||||
|
|
||||||
|
export const proAnalyticsDaily = (projectId: string, days?: number) =>
|
||||||
|
invoke<DailyStats[]>('plugin:agor-pro|pro_analytics_daily', { projectId, days });
|
||||||
|
|
||||||
|
export const proAnalyticsModelBreakdown = (projectId: string, days?: number) =>
|
||||||
|
invoke<ModelBreakdown[]>('plugin:agor-pro|pro_analytics_model_breakdown', { projectId, days });
|
||||||
|
|
||||||
|
// --- Export ---
|
||||||
|
|
||||||
|
export interface SessionReport {
|
||||||
|
projectId: string;
|
||||||
|
sessionId: string;
|
||||||
|
markdown: string;
|
||||||
|
costUsd: number;
|
||||||
|
turnCount: number;
|
||||||
|
toolCallCount: number;
|
||||||
|
durationMinutes: number;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSummaryReport {
|
||||||
|
projectId: string;
|
||||||
|
markdown: string;
|
||||||
|
totalSessions: number;
|
||||||
|
totalCostUsd: number;
|
||||||
|
periodDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const proExportSession = (projectId: string, sessionId: string) =>
|
||||||
|
invoke<SessionReport>('plugin:agor-pro|pro_export_session', { projectId, sessionId });
|
||||||
|
|
||||||
|
export const proExportProjectSummary = (projectId: string, days?: number) =>
|
||||||
|
invoke<ProjectSummaryReport>('plugin:agor-pro|pro_export_project_summary', { projectId, days });
|
||||||
|
|
||||||
|
// --- Profiles ---
|
||||||
|
|
||||||
|
export interface AccountProfile {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
email: string | null;
|
||||||
|
provider: string;
|
||||||
|
configDir: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveAccount {
|
||||||
|
profileId: string;
|
||||||
|
provider: string;
|
||||||
|
configDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const proListAccounts = () =>
|
||||||
|
invoke<AccountProfile[]>('plugin:agor-pro|pro_list_accounts');
|
||||||
|
|
||||||
|
export const proGetActiveAccount = () =>
|
||||||
|
invoke<ActiveAccount>('plugin:agor-pro|pro_get_active_account');
|
||||||
|
|
||||||
|
export const proSetActiveAccount = (profileId: string) =>
|
||||||
|
invoke<ActiveAccount>('plugin:agor-pro|pro_set_active_account', { profileId });
|
||||||
|
|
||||||
|
// --- Status ---
|
||||||
|
|
||||||
|
export const proStatus = () =>
|
||||||
|
invoke<string>('plugin:agor-pro|pro_status');
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
// SPDX-License-Identifier: LicenseRef-Commercial
|
// SPDX-License-Identifier: LicenseRef-Commercial
|
||||||
// Commercial-only tests — excluded from community test runs.
|
// Commercial-only tests — excluded from community test runs.
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock Tauri invoke for all pro bridge calls
|
||||||
|
vi.mock('@tauri-apps/api/core', () => ({
|
||||||
|
invoke: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('Pro Edition', () => {
|
describe('Pro Edition', () => {
|
||||||
it('commercial test suite is reachable', () => {
|
it('commercial test suite is reachable', () => {
|
||||||
|
|
@ -12,3 +17,119 @@ describe('Pro Edition', () => {
|
||||||
expect(process.env.AGOR_EDITION).toBe('pro');
|
expect(process.env.AGOR_EDITION).toBe('pro');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Pro Bridge Types', async () => {
|
||||||
|
const bridge = await import('../../src/lib/commercial/pro-bridge');
|
||||||
|
|
||||||
|
it('exports analytics functions', () => {
|
||||||
|
expect(typeof bridge.proAnalyticsSummary).toBe('function');
|
||||||
|
expect(typeof bridge.proAnalyticsDaily).toBe('function');
|
||||||
|
expect(typeof bridge.proAnalyticsModelBreakdown).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports export functions', () => {
|
||||||
|
expect(typeof bridge.proExportSession).toBe('function');
|
||||||
|
expect(typeof bridge.proExportProjectSummary).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports profile functions', () => {
|
||||||
|
expect(typeof bridge.proListAccounts).toBe('function');
|
||||||
|
expect(typeof bridge.proGetActiveAccount).toBe('function');
|
||||||
|
expect(typeof bridge.proSetActiveAccount).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports status function', () => {
|
||||||
|
expect(typeof bridge.proStatus).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pro Analytics Bridge', async () => {
|
||||||
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
|
const { proAnalyticsSummary, proAnalyticsDaily, proAnalyticsModelBreakdown } = await import('../../src/lib/commercial/pro-bridge');
|
||||||
|
const mockInvoke = vi.mocked(invoke);
|
||||||
|
|
||||||
|
it('proAnalyticsSummary calls correct plugin command', async () => {
|
||||||
|
mockInvoke.mockResolvedValueOnce({
|
||||||
|
totalSessions: 5, totalCostUsd: 1.25, totalTokens: 50000,
|
||||||
|
totalTurns: 30, totalToolCalls: 100, avgCostPerSession: 0.25,
|
||||||
|
avgTokensPerSession: 10000, periodDays: 30,
|
||||||
|
});
|
||||||
|
const result = await proAnalyticsSummary('proj-1', 30);
|
||||||
|
expect(mockInvoke).toHaveBeenCalledWith('plugin:agor-pro|pro_analytics_summary', { projectId: 'proj-1', days: 30 });
|
||||||
|
expect(result.totalSessions).toBe(5);
|
||||||
|
expect(result.avgCostPerSession).toBe(0.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proAnalyticsDaily returns array of daily stats', async () => {
|
||||||
|
mockInvoke.mockResolvedValueOnce([
|
||||||
|
{ date: '2026-03-15', sessionCount: 2, costUsd: 0.50, tokens: 20000, turns: 10, toolCalls: 30 },
|
||||||
|
{ date: '2026-03-16', sessionCount: 3, costUsd: 0.75, tokens: 30000, turns: 20, toolCalls: 50 },
|
||||||
|
]);
|
||||||
|
const result = await proAnalyticsDaily('proj-1');
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].date).toBe('2026-03-15');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proAnalyticsModelBreakdown returns model-level data', async () => {
|
||||||
|
mockInvoke.mockResolvedValueOnce([
|
||||||
|
{ model: 'opus', sessionCount: 3, totalCostUsd: 0.90, totalTokens: 40000, avgCostPerSession: 0.30 },
|
||||||
|
]);
|
||||||
|
const result = await proAnalyticsModelBreakdown('proj-1', 7);
|
||||||
|
expect(result[0].model).toBe('opus');
|
||||||
|
expect(mockInvoke).toHaveBeenCalledWith('plugin:agor-pro|pro_analytics_model_breakdown', { projectId: 'proj-1', days: 7 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pro Export Bridge', async () => {
|
||||||
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
|
const { proExportSession, proExportProjectSummary } = await import('../../src/lib/commercial/pro-bridge');
|
||||||
|
const mockInvoke = vi.mocked(invoke);
|
||||||
|
|
||||||
|
it('proExportSession returns markdown report', async () => {
|
||||||
|
mockInvoke.mockResolvedValueOnce({
|
||||||
|
projectId: 'proj-1', sessionId: 'sess-1', markdown: '# Report',
|
||||||
|
costUsd: 0.50, turnCount: 10, toolCallCount: 5, durationMinutes: 15.0, model: 'opus',
|
||||||
|
});
|
||||||
|
const result = await proExportSession('proj-1', 'sess-1');
|
||||||
|
expect(result.markdown).toContain('# Report');
|
||||||
|
expect(result.durationMinutes).toBe(15.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proExportProjectSummary returns period summary', async () => {
|
||||||
|
mockInvoke.mockResolvedValueOnce({
|
||||||
|
projectId: 'proj-1', markdown: '# Summary', totalSessions: 10, totalCostUsd: 5.0, periodDays: 30,
|
||||||
|
});
|
||||||
|
const result = await proExportProjectSummary('proj-1', 30);
|
||||||
|
expect(result.totalSessions).toBe(10);
|
||||||
|
expect(result.periodDays).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pro Profiles Bridge', async () => {
|
||||||
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
|
const { proListAccounts, proGetActiveAccount, proSetActiveAccount } = await import('../../src/lib/commercial/pro-bridge');
|
||||||
|
const mockInvoke = vi.mocked(invoke);
|
||||||
|
|
||||||
|
it('proListAccounts returns account list', async () => {
|
||||||
|
mockInvoke.mockResolvedValueOnce([
|
||||||
|
{ id: 'default', displayName: 'Default', email: null, provider: 'claude', configDir: '/home/.claude', isActive: true },
|
||||||
|
{ id: 'work', displayName: 'Work', email: 'work@co.com', provider: 'claude', configDir: '/home/.claude-work', isActive: false },
|
||||||
|
]);
|
||||||
|
const result = await proListAccounts();
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proSetActiveAccount calls correct command', async () => {
|
||||||
|
mockInvoke.mockResolvedValueOnce({ profileId: 'work', provider: 'claude', configDir: '/home/.claude-work' });
|
||||||
|
const result = await proSetActiveAccount('work');
|
||||||
|
expect(mockInvoke).toHaveBeenCalledWith('plugin:agor-pro|pro_set_active_account', { profileId: 'work' });
|
||||||
|
expect(result.profileId).toBe('work');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proGetActiveAccount returns current active', async () => {
|
||||||
|
mockInvoke.mockResolvedValueOnce({ profileId: 'default', provider: 'claude', configDir: '/home/.claude' });
|
||||||
|
const result = await proGetActiveAccount();
|
||||||
|
expect(result.profileId).toBe('default');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue