From 03fe2e22371a1cbe0803e65ebf04b3d95ba9ecd7 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Tue, 17 Mar 2026 01:52:46 +0100 Subject: [PATCH] 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. --- Cargo.lock | 2 + agor-pro/Cargo.toml | 2 + agor-pro/src/analytics.rs | 176 +++++++++++++++++++ agor-pro/src/export.rs | 241 +++++++++++++++++++++++++++ agor-pro/src/lib.rs | 22 ++- agor-pro/src/profiles.rs | 143 ++++++++++++++++ src/lib/commercial/pro-bridge.ts | 99 +++++++++++ tests/commercial/pro-edition.test.ts | 123 +++++++++++++- 8 files changed, 805 insertions(+), 3 deletions(-) create mode 100644 agor-pro/src/analytics.rs create mode 100644 agor-pro/src/export.rs create mode 100644 agor-pro/src/profiles.rs create mode 100644 src/lib/commercial/pro-bridge.ts diff --git a/Cargo.lock b/Cargo.lock index 7bd8fc4..2305e1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,7 +62,9 @@ name = "agor-pro" version = "0.1.0" dependencies = [ "agor-core", + "dirs 5.0.1", "log", + "rusqlite", "serde", "serde_json", "tauri", diff --git a/agor-pro/Cargo.toml b/agor-pro/Cargo.toml index 5ec3b27..a5e1126 100644 --- a/agor-pro/Cargo.toml +++ b/agor-pro/Cargo.toml @@ -8,6 +8,8 @@ license = "LicenseRef-Commercial" [dependencies] agor-core = { path = "../agor-core" } tauri = { version = "2.10.3", features = [] } +rusqlite = { version = "0.31", features = ["bundled-full"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" log = "0.4" +dirs = "5" diff --git a/agor-pro/src/analytics.rs b/agor-pro/src/analytics.rs new file mode 100644 index 0000000..8580b9b --- /dev/null +++ b/agor-pro/src/analytics.rs @@ -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) -> Result { + 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) -> Result, 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::, _>>() + .map_err(|e| format!("Row read failed: {e}"))?; + + Ok(rows) +} + +#[tauri::command] +pub fn pro_analytics_model_breakdown(project_id: String, days: Option) -> Result, 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::, _>>() + .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")); + } +} diff --git a/agor-pro/src/export.rs b/agor-pro/src/export.rs new file mode 100644 index 0000000..b2804eb --- /dev/null +++ b/agor-pro/src/export.rs @@ -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 { + 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, Option) = + 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::, _>>() + .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) -> Result { + 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)> = 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::, _>>() + .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")); + } +} diff --git a/agor-pro/src/lib.rs b/agor-pro/src/lib.rs index c74a7f4..031e6ec 100644 --- a/agor-pro/src/lib.rs +++ b/agor-pro/src/lib.rs @@ -4,17 +4,27 @@ // This crate is NOT open-source. It is distributed only via the // agents-orchestrator/agents-orchestrator private repository. +mod analytics; +mod export; +mod profiles; + use tauri::{ plugin::{Builder, TauriPlugin}, Runtime, }; -/// Initialize the agor-pro Tauri plugin. -/// Registers all commercial commands and managed state. pub fn init() -> TauriPlugin { Builder::new("agor-pro") .invoke_handler(tauri::generate_handler![ 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() } @@ -24,6 +34,14 @@ fn pro_status() -> String { "active".to_string() } +/// Open the sessions.db for the current data directory. +fn open_sessions_db() -> Result { + 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)] mod tests { use super::*; diff --git a/agor-pro/src/profiles.rs b/agor-pro/src/profiles.rs new file mode 100644 index 0000000..3ec4572 --- /dev/null +++ b/agor-pro/src/profiles.rs @@ -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, + 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, 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 = 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 { + 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 { + 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 { + let config = agor_core::config::AppConfig::from_env(); + Ok(config.config_dir.join("accounts.json")) +} + +fn active_account_path() -> Result { + 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")); + } +} diff --git a/src/lib/commercial/pro-bridge.ts b/src/lib/commercial/pro-bridge.ts new file mode 100644 index 0000000..7175637 --- /dev/null +++ b/src/lib/commercial/pro-bridge.ts @@ -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('plugin:agor-pro|pro_analytics_summary', { projectId, days }); + +export const proAnalyticsDaily = (projectId: string, days?: number) => + invoke('plugin:agor-pro|pro_analytics_daily', { projectId, days }); + +export const proAnalyticsModelBreakdown = (projectId: string, days?: number) => + invoke('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('plugin:agor-pro|pro_export_session', { projectId, sessionId }); + +export const proExportProjectSummary = (projectId: string, days?: number) => + invoke('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('plugin:agor-pro|pro_list_accounts'); + +export const proGetActiveAccount = () => + invoke('plugin:agor-pro|pro_get_active_account'); + +export const proSetActiveAccount = (profileId: string) => + invoke('plugin:agor-pro|pro_set_active_account', { profileId }); + +// --- Status --- + +export const proStatus = () => + invoke('plugin:agor-pro|pro_status'); diff --git a/tests/commercial/pro-edition.test.ts b/tests/commercial/pro-edition.test.ts index 7c2c322..bc291ad 100644 --- a/tests/commercial/pro-edition.test.ts +++ b/tests/commercial/pro-edition.test.ts @@ -1,7 +1,12 @@ // SPDX-License-Identifier: LicenseRef-Commercial // 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', () => { it('commercial test suite is reachable', () => { @@ -12,3 +17,119 @@ describe('Pro Edition', () => { 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'); + }); +});