use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::lfd::id::LfdId;
pub const TMUX_TERMINAL_SOURCE: &str = "wave_step_tmux";
pub const PALETTE_TERMINAL_SOURCE: &str = "palette";
pub const LF_CLI_SOURCE: &str = "lf_cli";
pub const WAVE_SERVER_SOURCE: &str = "wave_server";
pub const WAVE_SERVER_ENDPOINT_ENV: &str = "LF_WAVE_ENDPOINT";
pub const WAVE_SERVER_PID_ENV: &str = "LF_WAVE_SERVER_PID";
pub fn tmux_session_name(branch: &str) -> String {
format!("lf-{}", branch.replace(['.', ':'], "-"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum SessionStatus {
Pending,
Attached,
Running,
Succeeded,
Failed,
Canceled,
}
pub const LIVE_SESSION_STATUSES: &[SessionStatus] = &[
SessionStatus::Pending,
SessionStatus::Attached,
SessionStatus::Running,
];
impl SessionStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Attached => "attached",
Self::Running => "running",
Self::Succeeded => "succeeded",
Self::Failed => "failed",
Self::Canceled => "canceled",
}
}
pub(crate) fn as_i32(self) -> i32 {
match self {
Self::Pending => 0,
Self::Attached => 1,
Self::Running => 2,
Self::Succeeded => 3,
Self::Failed => 4,
Self::Canceled => 5,
}
}
pub(crate) fn from_i32(value: i32) -> Self {
match value {
0 => Self::Pending,
1 => Self::Attached,
2 => Self::Running,
3 => Self::Succeeded,
4 => Self::Failed,
5 => Self::Canceled,
_ => Self::Failed,
}
}
pub fn from_exit_code(exit_code: i32) -> Self {
if exit_code == 0 {
Self::Succeeded
} else {
Self::Failed
}
}
pub fn is_terminal(self) -> bool {
matches!(self, Self::Succeeded | Self::Failed | Self::Canceled)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum SessionUse {
WaveAgent,
Worker,
Palette,
}
impl SessionUse {
pub fn as_str(self) -> &'static str {
match self {
Self::WaveAgent => "wave_agent",
Self::Worker => "worker",
Self::Palette => "palette",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseSessionUseError {
value: String,
}
impl fmt::Display for ParseSessionUseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "unknown session use '{}'", self.value)
}
}
impl std::error::Error for ParseSessionUseError {}
impl TryFrom<&str> for SessionUse {
type Error = ParseSessionUseError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value {
"wave_agent" => Ok(Self::WaveAgent),
"worker" => Ok(Self::Worker),
"palette" => Ok(Self::Palette),
_ => Err(ParseSessionUseError {
value: value.to_string(),
}),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: LfdId,
pub wave_id: LfdId,
#[serde(skip_serializing_if = "Option::is_none")]
pub run_id: Option<LfdId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_session_id: Option<LfdId>,
#[serde(rename = "use")]
pub session_use: SessionUse,
pub step: String,
pub agent: String,
pub cwd: String,
#[serde(default)]
pub argv: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
pub source: String,
#[serde(default)]
pub tmux_name: String,
pub status: SessionStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub attached_at: Option<OffsetDateTime>,
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<OffsetDateTime>,
#[serde(skip_serializing_if = "Option::is_none")]
pub completed_at: Option<OffsetDateTime>,
pub created_at: OffsetDateTime,
#[serde(skip_serializing)]
pub completion_token: Option<String>,
}
impl Session {
pub fn is_tmux_backed(&self) -> bool {
matches!(
self.source.as_str(),
TMUX_TERMINAL_SOURCE | PALETTE_TERMINAL_SOURCE
)
}
pub fn attach(&mut self) -> bool {
if self.status.is_terminal() {
return false;
}
let mut changed = false;
if self.status == SessionStatus::Pending {
self.status = SessionStatus::Attached;
changed = true;
}
if self.attached_at.is_none() {
self.attached_at = Some(OffsetDateTime::now_utc());
changed = true;
}
changed
}
pub fn start(&mut self) -> bool {
if self.status.is_terminal() {
return false;
}
if self.attached_at.is_none() {
self.attached_at = Some(OffsetDateTime::now_utc());
}
self.status = SessionStatus::Running;
self.started_at = Some(OffsetDateTime::now_utc());
true
}
pub fn complete(&mut self, exit_code: i32) -> bool {
if self.status.is_terminal() {
return false;
}
self.status = SessionStatus::from_exit_code(exit_code);
self.completed_at = Some(OffsetDateTime::now_utc());
true
}
pub fn cancel(&mut self) -> bool {
if self.status.is_terminal() {
return false;
}
self.status = SessionStatus::Canceled;
self.completed_at = Some(OffsetDateTime::now_utc());
true
}
}
#[cfg(test)]
mod tests {
use super::{Session, SessionStatus, SessionUse};
use crate::lfd::id::LfdId;
use time::OffsetDateTime;
fn session(status: SessionStatus) -> Session {
Session {
id: LfdId::new(),
wave_id: LfdId::new(),
run_id: None,
parent_session_id: None,
session_use: SessionUse::Palette,
step: "design".to_string(),
agent: "claude".to_string(),
cwd: "/tmp/repo".to_string(),
argv: vec!["lf".to_string(), "design".to_string()],
env: Default::default(),
source: "wave_step".to_string(),
tmux_name: "lf-test-branch".to_string(),
status,
attached_at: None,
started_at: None,
completed_at: None,
created_at: OffsetDateTime::now_utc(),
completion_token: None,
}
}
#[test]
fn attach_marks_pending_sessions_attached() {
let mut session = session(SessionStatus::Pending);
assert!(session.attach());
assert_eq!(session.status, SessionStatus::Attached);
assert!(session.attached_at.is_some());
}
#[test]
fn start_auto_attaches_session() {
let mut session = session(SessionStatus::Pending);
assert!(session.start());
assert_eq!(session.status, SessionStatus::Running);
assert!(session.attached_at.is_some());
assert!(session.started_at.is_some());
}
#[test]
fn sessions_do_not_restart_or_complete_twice() {
let mut session = session(SessionStatus::Succeeded);
assert!(!session.start());
assert!(!session.complete(1));
assert!(!session.cancel());
}
#[test]
fn session_use_rejects_unknown_values() {
let err = SessionUse::try_from("legacy").unwrap_err();
assert_eq!(err.to_string(), "unknown session use 'legacy'");
}
}