use std::collections::HashMap;
use std::fs;
use std::time::SystemTime;
use alog::{MessageLevel, alog_channel, use_channel};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::capabilities::Dependency;
use crate::config::{self, Config};
use crate::proxy::UsageStats;
use_channel!("SESS");
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionCapabilityMeta {
pub capability_id: String,
#[serde(rename = "type")]
pub capability_type: String,
pub models: HashMap<String, String>,
pub model_type: Option<String>,
pub provider_id: Option<String>,
pub provider_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMeta {
pub session_id: String,
pub launched_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub finished_at: Option<String>,
#[serde(default)]
pub updated_at: String,
pub working_dir: String,
pub full_command: Vec<String>,
pub launcher_id: String,
pub launcher_type: String,
pub capabilities: Vec<SessionCapabilityMeta>,
pub usage: HashMap<String, UsageStats>,
}
pub fn generate_session_id() -> String {
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "unknown".to_string());
let escaped_cwd = cwd.replace('/', config::PATH_DELIM);
let epoch = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time is before Unix epoch");
let timestamp = format_utc_timestamp(epoch);
let suffix = short_suffix(epoch);
format!("{escaped_cwd}@{timestamp}_{suffix}")
}
pub fn build_capability_meta(
cap_cfg: &config::CapabilityConfig,
dependencies: &[Dependency],
config: &Config,
) -> SessionCapabilityMeta {
let models = crate::utils::capability_model_ids(cap_cfg, dependencies);
let (model_type, provider_id) = models
.values()
.next()
.and_then(|mid| config.models.get(mid.as_str()))
.map(|mc| (Some(mc.model_type.clone()), Some(mc.provider_id.clone())))
.unwrap_or((None, None));
let provider_type = provider_id
.as_deref()
.and_then(|pid| config.providers.get(pid))
.map(|pc| pc.provider_type.clone());
SessionCapabilityMeta {
capability_id: cap_cfg.capability_id.clone(),
capability_type: cap_cfg.capability_type.clone(),
models,
model_type,
provider_id,
provider_type,
}
}
pub fn create_session_meta(
session_id: &str,
config: &Config,
launcher_config: &config::LauncherConfig,
capabilities_with_deps: &[(config::CapabilityConfig, Vec<Dependency>)],
) -> SessionMeta {
let launched_at = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(format_utc_timestamp)
.unwrap_or_else(|_| "unknown".to_string());
let working_dir = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "unknown".to_string());
let capabilities_meta = capabilities_with_deps
.iter()
.map(|(cap_cfg, deps)| build_capability_meta(cap_cfg, deps, config))
.collect();
SessionMeta {
session_id: session_id.to_string(),
launched_at: launched_at.clone(),
finished_at: None,
updated_at: launched_at,
working_dir,
full_command: std::env::args().collect(),
launcher_id: launcher_config.launcher_id.clone(),
launcher_type: launcher_config.launcher_type.clone(),
capabilities: capabilities_meta,
usage: HashMap::new(),
}
}
pub fn write_session_file(session_meta: &SessionMeta) -> anyhow::Result<()> {
let sessions_dir = Config::sessions_dir()?;
fs::create_dir_all(&sessions_dir)?;
let path = sessions_dir.join(format!("{}.yaml", session_meta.session_id));
let content = serde_yaml::to_string(session_meta)
.with_context(|| "failed to serialize session metadata")?;
fs::write(&path, content)
.with_context(|| format!("failed to write session file: {}", path.display()))?;
alog_channel!(
MessageLevel::Debug3,
"session file written: {}",
path.display()
);
Ok(())
}
pub fn update_session_usage(
session_id: &str,
usage: &HashMap<String, UsageStats>,
) -> anyhow::Result<()> {
update_session_file(session_id, |meta| {
meta.usage = usage.clone();
})
}
pub fn finish_session(session_id: &str, usage: &HashMap<String, UsageStats>) -> anyhow::Result<()> {
let now = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(format_utc_timestamp)
.unwrap_or_else(|_| "unknown".to_string());
update_session_file(session_id, |meta| {
meta.usage = usage.clone();
meta.finished_at = Some(now);
})
}
pub fn read_session_file(session_id: &str) -> anyhow::Result<SessionMeta> {
let sessions_dir = Config::sessions_dir()?;
let path = sessions_dir.join(format!("{session_id}.yaml"));
let content = fs::read_to_string(&path)
.with_context(|| format!("failed to read session file: {}", path.display()))?;
let meta: SessionMeta = serde_yaml::from_str(&content)
.with_context(|| format!("failed to parse session file: {}", path.display()))?;
Ok(meta)
}
fn update_session_file(session_id: &str, f: impl FnOnce(&mut SessionMeta)) -> anyhow::Result<()> {
let sessions_dir = Config::sessions_dir()?;
let path = sessions_dir.join(format!("{session_id}.yaml"));
let content = fs::read_to_string(&path)
.with_context(|| format!("failed to read session file: {}", path.display()))?;
let mut meta: SessionMeta = serde_yaml::from_str(&content)
.with_context(|| format!("failed to parse session file: {}", path.display()))?;
f(&mut meta);
meta.updated_at = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(format_utc_timestamp)
.unwrap_or_else(|_| "unknown".to_string());
let updated = serde_yaml::to_string(&meta)
.with_context(|| "failed to serialize updated session metadata")?;
fs::write(&path, updated)
.with_context(|| format!("failed to update session file: {}", path.display()))?;
alog_channel!(
MessageLevel::Debug4,
"session file updated: {}",
path.display()
);
Ok(())
}
static SUFFIX_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn format_utc_timestamp(epoch: std::time::Duration) -> String {
let secs = epoch.as_secs() as i64;
chrono::DateTime::from_timestamp(secs, 0)
.expect("timestamp out of range")
.format("%Y%m%dT%H%M%S")
.to_string()
}
fn short_suffix(epoch: std::time::Duration) -> String {
let nanos = epoch.as_nanos() as u64;
let pid = std::process::id() as u64;
let counter = SUFFIX_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let combined = (nanos ^ pid.wrapping_mul(0x9e37_79b9) ^ counter) & 0xFFFF_FFFF;
format!("{combined:08x}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{CapabilityConfig, Config, ModelConfig, ProviderConfig, TestConfigHome};
#[test]
fn generate_session_id_contains_at_separator_and_hex_suffix() {
let id = generate_session_id();
assert!(id.contains('@'), "session id must contain '@': {id}");
let suffix = id.split('_').next_back().unwrap_or("");
assert_eq!(suffix.len(), 8, "suffix must be 8 chars: {id}");
assert!(
suffix.chars().all(|c| c.is_ascii_hexdigit()),
"suffix must be hex: {id}"
);
}
#[test]
fn consecutive_session_ids_are_distinct() {
let a = generate_session_id();
let b = generate_session_id();
assert_ne!(a, b, "consecutive session IDs must differ");
}
#[test]
fn format_utc_timestamp_epoch_zero() {
let ts = format_utc_timestamp(std::time::Duration::from_secs(0));
assert_eq!(ts, "19700101T000000");
}
#[test]
fn format_utc_timestamp_known_date() {
let ts = format_utc_timestamp(std::time::Duration::from_secs(1_735_689_600));
assert_eq!(ts, "20250101T000000");
}
#[test]
fn build_capability_meta_full_resolution() {
let mut config = Config::default();
config.models.insert(
"my-model".to_string(),
ModelConfig {
model_id: "my-model".to_string(),
model_type: "granite-3.1-8b-instruct".to_string(),
provider_id: "my-ollama".to_string(),
variant: None,
config: serde_json::json!({}),
},
);
config.providers.insert(
"my-ollama".to_string(),
ProviderConfig {
provider_id: "my-ollama".to_string(),
provider_type: "ollama".to_string(),
config: serde_json::json!({}),
},
);
let cap_cfg = CapabilityConfig {
capability_id: "chat".to_string(),
capability_type: "agent-model".to_string(),
config: serde_json::json!({ "model_id": "my-model" }),
};
let dependencies = vec![Dependency::Model {
config_key: "model_id".to_string(),
required: true,
requirement: crate::capabilities::ModelRequirement::default(),
resolved_id: None,
}];
let meta = build_capability_meta(&cap_cfg, &dependencies, &config);
assert_eq!(meta.capability_id, "chat");
assert_eq!(meta.capability_type, "agent-model");
assert_eq!(
meta.models.get("model_id").map(|s| s.as_str()),
Some("my-model")
);
assert_eq!(meta.model_type.as_deref(), Some("granite-3.1-8b-instruct"));
assert_eq!(meta.provider_id.as_deref(), Some("my-ollama"));
assert_eq!(meta.provider_type.as_deref(), Some("ollama"));
}
#[test]
fn build_capability_meta_missing_model_id_yields_empty_models() {
let config = Config::default();
let cap_cfg = CapabilityConfig {
capability_id: "chat".to_string(),
capability_type: "agent-model".to_string(),
config: serde_json::json!({}),
};
let dependencies = vec![Dependency::Model {
config_key: "model_id".to_string(),
required: false,
requirement: crate::capabilities::ModelRequirement::default(),
resolved_id: None,
}];
let meta = build_capability_meta(&cap_cfg, &dependencies, &config);
assert!(meta.models.is_empty());
assert!(meta.model_type.is_none());
assert!(meta.provider_id.is_none());
assert!(meta.provider_type.is_none());
}
#[test]
fn build_capability_meta_unknown_model_id_yields_nones_for_type_and_provider() {
let config = Config::default();
let cap_cfg = CapabilityConfig {
capability_id: "chat".to_string(),
capability_type: "agent-model".to_string(),
config: serde_json::json!({ "model_id": "nonexistent" }),
};
let dependencies = vec![Dependency::Model {
config_key: "model_id".to_string(),
required: false,
requirement: crate::capabilities::ModelRequirement::default(),
resolved_id: None,
}];
let meta = build_capability_meta(&cap_cfg, &dependencies, &config);
assert_eq!(
meta.models.get("model_id").map(|s| s.as_str()),
Some("nonexistent")
);
assert!(meta.model_type.is_none());
assert!(meta.provider_id.is_none());
assert!(meta.provider_type.is_none());
}
#[test]
fn session_meta_round_trips_yaml() {
let meta = SessionMeta {
session_id: "test---home@20250101T120000_abcd1234".to_string(),
launched_at: "20250101T120000".to_string(),
finished_at: None,
updated_at: "20250101T120000".to_string(),
working_dir: "/home/test".to_string(),
full_command: vec![
"granite-cli".to_string(),
"launch".to_string(),
"claude".to_string(),
],
launcher_id: "claude".to_string(),
launcher_type: "claude".to_string(),
capabilities: vec![],
usage: HashMap::new(),
};
let yaml = serde_yaml::to_string(&meta).unwrap();
let back: SessionMeta = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(back.session_id, meta.session_id);
assert_eq!(back.launcher_id, meta.launcher_id);
assert_eq!(back.full_command, meta.full_command);
assert!(back.finished_at.is_none());
assert!(back.usage.is_empty());
}
#[test]
fn session_meta_finished_at_is_omitted_when_none() {
let meta = SessionMeta {
session_id: "s".to_string(),
launched_at: "20250101T120000".to_string(),
finished_at: None,
updated_at: "20250101T120000".to_string(),
working_dir: "/".to_string(),
full_command: vec![],
launcher_id: "claude".to_string(),
launcher_type: "claude".to_string(),
capabilities: vec![],
usage: HashMap::new(),
};
let yaml = serde_yaml::to_string(&meta).unwrap();
assert!(
!yaml.contains("finished_at"),
"finished_at must be absent from YAML when None: {yaml}"
);
}
fn make_meta(session_id: &str, launcher_id: &str) -> SessionMeta {
SessionMeta {
session_id: session_id.to_string(),
launched_at: "20250101T120000".to_string(),
finished_at: None,
updated_at: "20250101T120000".to_string(),
working_dir: "/test".to_string(),
full_command: vec!["granite-cli".to_string(), "launch".to_string()],
launcher_id: launcher_id.to_string(),
launcher_type: launcher_id.to_string(),
capabilities: vec![],
usage: HashMap::new(),
}
}
#[test]
fn write_and_read_session_file_round_trips() {
let _home = TestConfigHome::new();
Config::ensure_directories_for_test();
let session_id = "test---home@20250101T120000_deadbeef";
write_session_file(&make_meta(session_id, "claude")).unwrap();
let read_back = read_session_file(session_id).unwrap();
assert_eq!(read_back.session_id, session_id);
assert_eq!(read_back.launcher_id, "claude");
assert!(read_back.finished_at.is_none());
}
#[test]
fn update_session_usage_writes_new_usage_and_preserves_other_fields() {
let _home = TestConfigHome::new();
Config::ensure_directories_for_test();
let session_id = "test---home@20250101T130000_cafebabe";
write_session_file(&make_meta(session_id, "opencode")).unwrap();
let mut usage = HashMap::new();
usage.insert(
"agent".to_string(),
UsageStats {
requests: 3,
input_tokens: 100,
output_tokens: 50,
cache_creation_tokens: 0,
cache_read_tokens: 10,
},
);
update_session_usage(session_id, &usage).unwrap();
let updated = read_session_file(session_id).unwrap();
assert_eq!(updated.launcher_id, "opencode");
assert!(updated.finished_at.is_none());
let agent_usage = updated.usage.get("agent").unwrap();
assert_eq!(agent_usage.requests, 3);
assert_eq!(agent_usage.input_tokens, 100);
}
#[test]
fn finish_session_sets_finished_at_and_final_usage() {
let _home = TestConfigHome::new();
Config::ensure_directories_for_test();
let session_id = "test---home@20250101T140000_f1n15hed";
write_session_file(&make_meta(session_id, "claude")).unwrap();
let mut usage = HashMap::new();
usage.insert(
"main".to_string(),
UsageStats {
requests: 7,
input_tokens: 200,
output_tokens: 80,
cache_creation_tokens: 5,
cache_read_tokens: 15,
},
);
finish_session(session_id, &usage).unwrap();
let finished = read_session_file(session_id).unwrap();
assert!(
finished.finished_at.is_some(),
"finished_at must be set after finish_session"
);
let ts = finished.finished_at.unwrap();
assert_eq!(ts.len(), 15, "timestamp must be YYYYMMDDTHHMMSS: {ts}");
assert!(ts.contains('T'), "timestamp must contain 'T': {ts}");
assert_eq!(finished.usage.get("main").unwrap().requests, 7);
assert_eq!(finished.launched_at, "20250101T120000");
}
#[test]
fn session_meta_round_trips_yaml_with_updated_at() {
let meta = SessionMeta {
session_id: "s".to_string(),
launched_at: "20250101T120000".to_string(),
finished_at: None,
updated_at: "20250101T120500".to_string(),
working_dir: "/".to_string(),
full_command: vec![],
launcher_id: "claude".to_string(),
launcher_type: "claude".to_string(),
capabilities: vec![],
usage: HashMap::new(),
};
let yaml = serde_yaml::to_string(&meta).unwrap();
let back: SessionMeta = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(back.updated_at, "20250101T120500");
}
#[test]
fn session_meta_updated_at_defaults_to_empty_string_when_missing_from_yaml() {
let yaml = "session_id: s\nlaunched_at: 20250101T120000\nworking_dir: /\nfull_command: []\nlauncher_id: claude\nlauncher_type: claude\ncapabilities: []\nusage: {}\n";
let meta: SessionMeta = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
meta.updated_at, "",
"updated_at must default to empty string for old files"
);
}
#[test]
fn update_session_file_stamps_updated_at() {
let _home = TestConfigHome::new();
Config::ensure_directories_for_test();
let session_id = "test---home@20250101T150000_updcheck";
write_session_file(&make_meta(session_id, "claude")).unwrap();
let usage = HashMap::new();
update_session_usage(session_id, &usage).unwrap();
let updated = read_session_file(session_id).unwrap();
assert!(
!updated.updated_at.is_empty(),
"updated_at must be set after update_session_usage"
);
assert_eq!(
updated.updated_at.len(),
15,
"updated_at must be YYYYMMDDTHHMMSS"
);
assert!(updated.updated_at.contains('T'));
}
}