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,
#[serde(default)]
pub ephemeral: bool,
#[serde(default)]
pub workspace_owned: bool,
#[serde(default)]
pub source_id: Option<String>,
#[serde(default)]
pub claude_session_id: Option<String>,
#[serde(default)]
pub scrollback_path: Option<PathBuf>,
#[serde(default)]
pub last_cwd: Option<PathBuf>,
}
#[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(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
};
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(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
};
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(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
};
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(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
};
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);
}
#[test]
fn record_without_ephemeral_field_defaults_to_false() {
let legacy_json = serde_json::json!({
"id": ManagedSessionId::new(),
"tmux_name": "tmpm-legacy",
"cwd": "/tmp",
"task": "legacy task",
"state": "stopped",
"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!(
!back.ephemeral,
"a record with no `ephemeral` key must default to false (non-ephemeral)"
);
}
#[test]
fn record_round_trips_ephemeral_true() {
let record = SessionRecord {
id: ManagedSessionId::new(),
tmux_name: "tmpm-ephemeral".into(),
cwd: PathBuf::from("/tmp"),
task: "throwaway".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(),
ephemeral: true,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
};
let json = serde_json::to_string(&record).expect("serialize");
let back: SessionRecord = serde_json::from_str(&json).expect("deserialize");
assert!(back.ephemeral, "ephemeral=true must round-trip");
}
#[test]
fn record_without_workspace_owned_field_defaults_to_false() {
let legacy_json = serde_json::json!({
"id": ManagedSessionId::new(),
"tmux_name": "tmpm-legacy",
"cwd": "/tmp",
"task": "legacy task",
"state": "stopped",
"created_at": Utc::now().to_rfc3339(),
"last_activity_at": null,
"workspace_path": "/tmp/some-workspace",
"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!(
!back.workspace_owned,
"a record with no `workspace_owned` key must default to false (unowned — safe)"
);
}
#[test]
fn record_without_scrollback_fields_defaults_to_none() {
let legacy_json = serde_json::json!({
"id": ManagedSessionId::new(),
"tmux_name": "tmpm-legacy",
"cwd": "/tmp",
"task": "legacy task",
"state": "stopped",
"created_at": Utc::now().to_rfc3339(),
"last_activity_at": null,
"workspace_path": "/tmp/ws",
"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!(
back.scrollback_path.is_none(),
"scrollback_path must default to None for legacy records"
);
assert!(
back.last_cwd.is_none(),
"last_cwd must default to None for legacy records"
);
}
#[test]
fn record_round_trips_scrollback_fields() {
let record = SessionRecord {
id: ManagedSessionId::new(),
tmux_name: "tmpm-snap".into(),
cwd: PathBuf::from("/home/user/project"),
task: "add feature".into(),
state: ManagedSessionState::Stopped,
created_at: Utc::now(),
last_activity_at: None,
workspace_path: Some(PathBuf::from("/managed/ws")),
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: true,
source_id: None,
claude_session_id: None,
scrollback_path: Some(PathBuf::from("/managed/ws/.trusty-mpm/scrollback.txt")),
last_cwd: Some(PathBuf::from("/managed/ws/src")),
};
let json = serde_json::to_string(&record).expect("serialize");
let back: SessionRecord = serde_json::from_str(&json).expect("deserialize");
assert_eq!(
back.scrollback_path,
Some(PathBuf::from("/managed/ws/.trusty-mpm/scrollback.txt"))
);
assert_eq!(back.last_cwd, Some(PathBuf::from("/managed/ws/src")));
}
#[test]
fn record_round_trips_workspace_owned_true() {
let record = SessionRecord {
id: ManagedSessionId::new(),
tmux_name: "tmpm-clone".into(),
cwd: PathBuf::from("/managed/root/owner/repo/abc"),
task: "fix bug".into(),
state: ManagedSessionState::Active,
created_at: Utc::now(),
last_activity_at: None,
workspace_path: Some(PathBuf::from("/managed/root/owner/repo/abc")),
repo_url: Some("https://github.com/owner/repo".into()),
branch: Some("fix/thing".into()),
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: true,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
};
let json = serde_json::to_string(&record).expect("serialize");
let back: SessionRecord = serde_json::from_str(&json).expect("deserialize");
assert!(back.workspace_owned, "workspace_owned=true must round-trip");
}
}