use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize)]
pub struct BitcoindState {
pub mode: String,
pub rpc_url: String,
pub rpc_user: String,
pub container_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InstanceState {
pub name: String,
pub session_path: PathBuf,
pub base_url: String,
pub ldk_addr: String,
pub node_id: String,
pub pid: Option<u32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ChannelState {
pub from: String,
pub to: String,
pub capacity_sats: u64,
pub status: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct HarnessState {
pub created_at: String,
pub bitcoind: BitcoindState,
pub instances: Vec<InstanceState>,
pub channel: Option<ChannelState>,
#[serde(default)]
pub supervisor_pid: Option<u32>,
}
pub fn state_path() -> Result<PathBuf> {
let base = match std::env::var("XDG_CACHE_HOME") {
Ok(c) if !c.is_empty() => PathBuf::from(c),
_ => {
let home = std::env::var_os("HOME")
.ok_or_else(|| anyhow!("$HOME not set"))?;
PathBuf::from(home).join(".cache")
}
};
Ok(base.join("node-app").join("harness").join("harness-state.json"))
}
impl HarnessState {
pub fn load() -> Result<Self> {
let path = state_path()?;
let bytes = std::fs::read(&path).with_context(|| {
format!(
"no harness state at {} — run `node-app harness up` first",
path.display()
)
})?;
serde_json::from_slice(&bytes).context("parse harness-state.json")
}
pub fn save(&self) -> Result<PathBuf> {
let path = state_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("create harness state dir {}", parent.display())
})?;
}
let json = serde_json::to_string_pretty(self).context("serialize harness state")?;
std::fs::write(&path, json).with_context(|| format!("write {}", path.display()))?;
Ok(path)
}
pub fn instance(&self, name: &str) -> Result<&InstanceState> {
self.instances
.iter()
.find(|i| i.name == name)
.ok_or_else(|| anyhow!("unknown instance '{name}'; known: {}", self.known_names()))
}
fn known_names(&self) -> String {
self.instances
.iter()
.map(|i| i.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_serializes_and_round_trips() {
let state = HarnessState {
created_at: "2026-07-01T00:00:00Z".into(),
bitcoind: BitcoindState {
mode: "docker".into(),
rpc_url: "http://127.0.0.1:18443".into(),
rpc_user: "polaruser".into(),
container_id: Some("abc123".into()),
},
instances: vec![InstanceState {
name: "alice".into(),
session_path: "/tmp/alice-agent-session.json".into(),
base_url: "http://127.0.0.1:3001".into(),
ldk_addr: "127.0.0.1:9937".into(),
node_id: "03aa".into(),
pid: Some(4242),
}],
channel: None,
supervisor_pid: Some(9999),
};
let json = serde_json::to_string(&state).unwrap();
let back: HarnessState = serde_json::from_str(&json).unwrap();
assert_eq!(back.instance("alice").unwrap().base_url, "http://127.0.0.1:3001");
assert!(back.instance("bob").is_err());
assert_eq!(back.supervisor_pid, Some(9999));
}
}