use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use super::config::{PodConfig, expand_path};
use super::error::{PodError, Result};
pub const STATE_DIR_ENV: &str = "FORGE_POD_STATE_DIR";
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct StateFile {
#[serde(default)]
pub pods: BTreeMap<String, PodState>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PodState {
pub status: String,
pub started_at: String,
pub source_path: String,
pub windows: Vec<WindowState>,
#[serde(default)]
pub on_pane_dead: Vec<String>,
#[serde(default)]
pub after_detach: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WindowState {
pub name: String,
pub panes: Vec<PaneState>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PaneState {
pub index: usize,
pub cwd: String,
pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task: Option<String>,
}
pub fn state_dir() -> PathBuf {
match std::env::var_os(STATE_DIR_ENV) {
Some(dir) if !dir.is_empty() => PathBuf::from(dir),
_ => expand_path("~/.cosmux"),
}
}
pub fn state_path() -> PathBuf {
state_dir().join("state.json")
}
#[cfg(test)]
fn assert_write_allowed(path: &Path) {
let temp = std::env::temp_dir();
assert!(
path.starts_with(&temp),
"REFUSING to write pod state outside the temp dir during tests: {}\n\
The live store tracks running fleet pods. Set {STATE_DIR_ENV} to a temp path in this test.",
path.display()
);
}
#[cfg(not(test))]
fn assert_write_allowed(_path: &Path) {}
pub fn load() -> Result<StateFile> {
load_from(&state_path())
}
pub fn load_from(path: &Path) -> Result<StateFile> {
if !path.exists() {
return Ok(StateFile::default());
}
let raw = std::fs::read_to_string(path)?;
parse(&raw)
}
pub fn parse(raw: &str) -> Result<StateFile> {
serde_json::from_str(raw).map_err(|e| PodError::Other(anyhow::anyhow!("state.json parse: {e}")))
}
pub fn save(state: &StateFile) -> Result<()> {
super::journal::require_production_write()?;
let dir = state_dir();
let path = dir.join("state.json");
assert_write_allowed(&path);
std::fs::create_dir_all(&dir)?;
let raw = serde_json::to_string_pretty(state)
.map_err(|e| PodError::Other(anyhow::anyhow!("state.json serialize: {e}")))?;
std::fs::write(&path, raw)?;
Ok(())
}
pub fn build_pod_state(pod: &PodConfig, source_path: &Path, started_at: String) -> PodState {
let pod_root = pod.expanded_root();
let windows = pod
.windows
.iter()
.map(|w| WindowState {
name: w.name.clone(),
panes: w
.panes
.iter()
.enumerate()
.map(|(i, p)| {
let cwd = match (&p.cwd, &pod_root) {
(Some(c), _) => expand_path(c).display().to_string(),
(None, Some(r)) => r.display().to_string(),
(None, None) => String::from("."),
};
PaneState {
index: i,
cwd,
command: p.command.clone().unwrap_or_default(),
task: p.task.clone(),
}
})
.collect(),
})
.collect();
PodState {
status: "running".into(),
started_at,
source_path: source_path.display().to_string(),
windows,
on_pane_dead: pod.on_pane_dead.clone(),
after_detach: pod.after_detach.clone(),
}
}
pub fn record_spawn(pod: &PodConfig, source_path: &Path) -> Result<()> {
let mut state = load()?;
state.pods.insert(
pod.name.clone(),
build_pod_state(pod, source_path, now_iso8601()),
);
save(&state)
}
pub fn record_stop(name: &str) -> Result<()> {
let mut state = load()?;
state.pods.remove(name);
save(&state)
}
pub fn pod(name: &str) -> Result<Option<PodState>> {
Ok(load()?.pods.get(name).cloned())
}
fn now_iso8601() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pod::ENV_LOCK;
struct StoreGuard {
_dir: tempdir::TempDir,
_journal_dir: tempdir::TempDir,
previous: Option<std::ffi::OsString>,
previous_journal: Option<std::ffi::OsString>,
_lock: std::sync::MutexGuard<'static, ()>,
}
mod tempdir {
use std::path::{Path, PathBuf};
pub struct TempDir(PathBuf);
impl TempDir {
pub fn new(tag: &str) -> Self {
let base = std::env::temp_dir().join(format!(
"forge-pod-{}-{}-{:?}",
tag,
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).expect("create temp store");
Self(base)
}
pub fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
}
impl StoreGuard {
fn new(tag: &str) -> Self {
Self::build(tag, true)
}
fn unadopted(tag: &str) -> Self {
Self::build(tag, false)
}
fn build(tag: &str, adopted: bool) -> Self {
let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempdir::TempDir::new(tag);
let journal_dir = tempdir::TempDir::new(&format!("{tag}-journal"));
let previous = std::env::var_os(STATE_DIR_ENV);
let previous_journal = std::env::var_os(super::super::journal::JOURNAL_DIR_ENV);
unsafe { std::env::set_var(STATE_DIR_ENV, dir.path()) };
unsafe {
std::env::set_var(super::super::journal::JOURNAL_DIR_ENV, journal_dir.path())
};
if adopted {
let mut journal = super::super::journal::Journal::new(
super::super::journal::TransitionState::Adopted,
);
journal.steps.shim = super::super::journal::StepState::Complete;
std::fs::write(
journal_dir.path().join("pod-adoption.json"),
serde_json::to_string_pretty(&journal).unwrap(),
)
.unwrap();
}
Self {
_dir: dir,
_journal_dir: journal_dir,
previous,
previous_journal,
_lock: lock,
}
}
}
impl Drop for StoreGuard {
fn drop(&mut self) {
match &self.previous {
Some(value) => unsafe { std::env::set_var(STATE_DIR_ENV, value) },
None => unsafe { std::env::remove_var(STATE_DIR_ENV) },
}
match &self.previous_journal {
Some(value) => unsafe {
std::env::set_var(super::super::journal::JOURNAL_DIR_ENV, value)
},
None => unsafe { std::env::remove_var(super::super::journal::JOURNAL_DIR_ENV) },
}
}
}
const LIVE_SHAPE: &str = r#"{
"pods": {
"Dx3_Program": {
"status": "running",
"started_at": "2026-04-19T21:59:48Z",
"source_path": "~/ASIF/infra/tmux/Dx3_Program.yaml",
"windows": [
{ "name": "claude", "panes": [ { "index": 0, "cwd": "~/projects/dx3", "command": "ccyolo" } ] }
]
}
}
}"#;
#[test]
fn parses_the_live_store_shape() {
let state = parse(LIVE_SHAPE).expect("live shape must parse");
let pod = state.pods.get("Dx3_Program").expect("pod present");
assert_eq!(pod.status, "running");
assert_eq!(pod.windows[0].panes[0].command, "ccyolo");
assert!(
pod.on_pane_dead.is_empty(),
"absent hook lists default to empty, not an error"
);
assert_eq!(pod.windows[0].panes[0].task, None);
}
#[test]
fn round_trip_does_not_introduce_a_task_field() {
let state = parse(LIVE_SHAPE).unwrap();
let out = serde_json::to_string(&state).unwrap();
assert!(!out.contains("task"), "{out}");
}
#[test]
fn state_dir_defaults_to_the_cosmux_store() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let previous = std::env::var_os(STATE_DIR_ENV);
unsafe { std::env::remove_var(STATE_DIR_ENV) };
let resolved = state_dir();
if let Some(value) = previous {
unsafe { std::env::set_var(STATE_DIR_ENV, value) };
}
assert!(
resolved.ends_with(".cosmux"),
"production must use the cosmux store in place, got {}",
resolved.display()
);
}
#[test]
fn save_and_load_round_trip_within_the_temp_store() {
let _guard = StoreGuard::new("roundtrip");
let mut state = StateFile::default();
state.pods.insert(
"test-pod".into(),
PodState {
status: "running".into(),
started_at: "2026-07-18T00:00:00Z".into(),
source_path: "/tmp/test-pod.yaml".into(),
windows: vec![],
on_pane_dead: vec![],
after_detach: vec![],
},
);
save(&state).expect("save to temp store");
assert_eq!(load().expect("load back"), state);
assert!(
state_path().starts_with(std::env::temp_dir()),
"guard must redirect the store"
);
}
#[test]
#[should_panic(expected = "REFUSING to write pod state outside the temp dir")]
fn writing_outside_temp_panics_under_test() {
assert_write_allowed(&expand_path("~/.cosmux/state.json"));
}
#[test]
fn a_store_write_is_refused_until_the_journal_says_adopted() {
let _guard = StoreGuard::unadopted("refusal");
let error = save(&StateFile::default()).expect_err("unadopted forge must not write");
let message = error.to_string();
assert!(message.contains("not adopted"), "{message}");
assert!(message.contains("forge pod adopt"), "{message}");
assert!(
!state_path().exists(),
"a refused write must not create the store"
);
}
#[test]
fn a_corrupt_journal_freezes_store_writes_rather_than_allowing_them() {
let guard = StoreGuard::unadopted("frozen");
std::fs::write(
guard._journal_dir.path().join("pod-adoption.json"),
"{ trunc",
)
.unwrap();
let error = save(&StateFile::default()).expect_err("a corrupt journal must freeze writes");
assert!(error.to_string().contains("recovery"), "{error}");
}
#[test]
fn missing_store_loads_as_empty_not_an_error() {
let _guard = StoreGuard::new("missing");
let state = load().expect("absent store is not an error");
assert!(state.pods.is_empty());
}
#[test]
fn now_is_iso8601_zulu() {
let now = now_iso8601();
assert_eq!(now.len(), 20, "{now}");
assert!(now.ends_with('Z'), "{now}");
assert_eq!(&now[4..5], "-", "{now}");
assert_eq!(&now[10..11], "T", "{now}");
}
}