use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::PathBuf;
use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ManagedSessionId(pub Uuid);
impl ManagedSessionId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn as_uuid(&self) -> &Uuid {
&self.0
}
}
impl Default for ManagedSessionId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for ManagedSessionId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl From<Uuid> for ManagedSessionId {
fn from(uuid: Uuid) -> Self {
Self(uuid)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ManagedSessionState {
Provisioning,
Active,
Stopped,
Errored,
Decommissioned,
}
impl fmt::Display for ManagedSessionState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Provisioning => "provisioning",
Self::Active => "active",
Self::Stopped => "stopped",
Self::Errored => "errored",
Self::Decommissioned => "decommissioned",
};
write!(f, "{s}")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionRecord {
pub id: ManagedSessionId,
pub tmux_name: String,
pub cwd: PathBuf,
pub task: String,
pub state: ManagedSessionState,
pub created_at: DateTime<Utc>,
pub last_activity_at: Option<DateTime<Utc>>,
pub workspace_path: Option<PathBuf>,
pub repo_url: Option<String>,
pub branch: Option<String>,
pub pending_decision: Option<String>,
pub proposed_default: Option<String>,
#[serde(default)]
pub correlation: crate::driver::SessionCorrelation,
#[serde(default)]
pub runtime: crate::runtime::RuntimeKind,
}
#[derive(Debug, Error)]
pub enum RecordError {
#[error("invalid session record: {0}")]
Invalid(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn managed_session_id_round_trip() {
let id = ManagedSessionId::new();
let json = serde_json::to_string(&id).expect("serialize");
let back: ManagedSessionId = serde_json::from_str(&json).expect("deserialize");
assert_eq!(id, back);
assert_eq!(id.as_uuid(), back.as_uuid());
}
#[test]
fn state_display() {
assert_eq!(
ManagedSessionState::Provisioning.to_string(),
"provisioning"
);
assert_eq!(ManagedSessionState::Active.to_string(), "active");
assert_eq!(ManagedSessionState::Stopped.to_string(), "stopped");
assert_eq!(ManagedSessionState::Errored.to_string(), "errored");
assert_eq!(
ManagedSessionState::Decommissioned.to_string(),
"decommissioned"
);
}
#[test]
fn record_serde_round_trip() {
let record = SessionRecord {
id: ManagedSessionId::new(),
tmux_name: "tmpm-quiet-falcon".into(),
cwd: PathBuf::from("/tmp/project"),
task: "implement feature X".into(),
state: ManagedSessionState::Active,
created_at: Utc::now(),
last_activity_at: Some(Utc::now()),
workspace_path: None,
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
};
let json = serde_json::to_string(&record).expect("serialize");
let back: SessionRecord = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.id, record.id);
assert_eq!(back.tmux_name, record.tmux_name);
assert_eq!(back.state, record.state);
}
#[test]
fn stopped_state_survives_serde() {
let record = SessionRecord {
id: ManagedSessionId::new(),
tmux_name: "tmpm-test".into(),
cwd: PathBuf::from("/tmp"),
task: "task".into(),
state: ManagedSessionState::Stopped,
created_at: Utc::now(),
last_activity_at: None,
workspace_path: Some(PathBuf::from("/tmp/ws")),
repo_url: Some("https://github.com/owner/repo".into()),
branch: Some("main".into()),
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
};
let json = serde_json::to_string(&record).expect("serialize");
let back: SessionRecord = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.state, ManagedSessionState::Stopped);
assert_eq!(back.workspace_path, record.workspace_path);
}
#[test]
fn decommissioned_state_survives_serde() {
let record = SessionRecord {
id: ManagedSessionId::new(),
tmux_name: "tmpm-gone".into(),
cwd: PathBuf::from("/tmp"),
task: "task".into(),
state: ManagedSessionState::Decommissioned,
created_at: Utc::now(),
last_activity_at: None,
workspace_path: None, repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
};
let json = serde_json::to_string(&record).expect("serialize");
let back: SessionRecord = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.state, ManagedSessionState::Decommissioned);
assert!(back.workspace_path.is_none());
}
#[test]
fn record_without_runtime_field_defaults_to_claude_code() {
let legacy_json = serde_json::json!({
"id": ManagedSessionId::new(),
"tmux_name": "tmpm-legacy",
"cwd": "/tmp",
"task": "legacy task",
"state": "active",
"created_at": Utc::now().to_rfc3339(),
"last_activity_at": null,
"workspace_path": null,
"repo_url": null,
"branch": null,
"pending_decision": null,
"proposed_default": null
})
.to_string();
let back: SessionRecord = serde_json::from_str(&legacy_json).expect("deserialize legacy");
assert_eq!(back.runtime, crate::runtime::RuntimeKind::ClaudeCode);
}
#[test]
fn record_round_trips_tcode_runtime() {
let mut record = SessionRecord {
id: ManagedSessionId::new(),
tmux_name: "tmpm-tcode".into(),
cwd: PathBuf::from("/tmp"),
task: "task".into(),
state: ManagedSessionState::Active,
created_at: Utc::now(),
last_activity_at: None,
workspace_path: None,
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
};
record.runtime = crate::runtime::RuntimeKind::Tcode;
let json = serde_json::to_string(&record).expect("serialize");
let back: SessionRecord = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.runtime, crate::runtime::RuntimeKind::Tcode);
}
}