use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct BitcoindState {
pub mode: String,
pub rpc_url: String,
pub rpc_user: String,
pub container_id: Option<String>,
}
#[derive(Debug, Default, 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>,
#[serde(default)]
pub client_node_ports: Option<super::ports::PortSet>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ChannelState {
pub from: String,
pub to: String,
pub capacity_sats: u64,
pub status: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct HarnessState {
pub created_at: String,
#[serde(default)]
pub monorepo_path: PathBuf,
pub bitcoind: BitcoindState,
pub instances: Vec<InstanceState>,
pub channel: Option<ChannelState>,
#[serde(default)]
pub supervisor_pid: Option<u32>,
#[serde(default)]
pub client_node_instance: Option<String>,
#[serde(default)]
pub operation_mode: bool,
#[serde(default)]
pub pwa_dist_built: Option<bool>,
}
fn harness_root() -> 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"))
}
pub fn state_path(monorepo_path: &Path) -> Result<PathBuf> {
let mut h = DefaultHasher::new();
monorepo_path
.canonicalize()
.unwrap_or_else(|_| monorepo_path.to_path_buf())
.hash(&mut h);
Ok(harness_root()?
.join(format!("monorepo-{:x}", h.finish()))
.join("harness-state.json"))
}
pub fn lock_path(monorepo_path: &Path) -> Result<PathBuf> {
Ok(state_path(monorepo_path)?.with_file_name("harness.lock"))
}
pub struct HarnessLock {
path: PathBuf,
}
impl HarnessLock {
pub fn acquire(monorepo_path: &Path) -> Result<Self> {
let path = lock_path(monorepo_path)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create harness state dir {}", parent.display()))?;
}
loop {
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
use std::io::Write;
let _ = write!(file, "{}", std::process::id());
return Ok(Self { path });
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
let holder = std::fs::read_to_string(&path)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok());
match holder {
Some(pid) if pid_is_alive(pid) => {
anyhow::bail!(
"another `harness up` (PID {pid}) already owns this checkout.\n\
Harness state is per-checkout, so a second run would delete the \
first's state and orphan its daemons.\n\
Stop it with `node-app harness down`, or run from a separate \
checkout/worktree."
);
}
_ => {
let _ = std::fs::remove_file(&path);
continue;
}
}
}
Err(e) => {
return Err(e).with_context(|| format!("create lock {}", path.display()));
}
}
}
}
}
impl Drop for HarnessLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
pub fn pid_is_alive(pid: u32) -> bool {
#[cfg(unix)]
{
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
}
#[cfg(not(unix))]
{
let _ = pid;
false
}
}
fn all_state_paths() -> Result<Vec<PathBuf>> {
let root = harness_root()?;
let Ok(entries) = std::fs::read_dir(&root) else {
return Ok(Vec::new());
};
let mut found: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path().join("harness-state.json"))
.filter(|p| p.is_file())
.collect();
let legacy = root.join("harness-state.json");
if legacy.is_file() {
found.push(legacy);
}
found.sort();
Ok(found)
}
pub fn resolve_state_path() -> Result<PathBuf> {
let cwd = std::env::current_dir().context("resolve current directory")?;
let preferred = state_path(&cwd)?;
if preferred.is_file() {
return Ok(preferred);
}
let candidates = all_state_paths()?;
match candidates.len() {
0 => Err(anyhow!(
"no harness state for {} — run `node-app harness up` first",
cwd.display()
)),
1 => {
let only = candidates.into_iter().next().expect("len checked");
eprintln!(
"harness: no stack for {}; using the only running harness ({})",
cwd.display(),
state_owner(&only)
);
Ok(only)
}
_ => {
let listed = candidates
.iter()
.map(|p| format!(" {}", state_owner(p)))
.collect::<Vec<_>>()
.join("\n");
Err(anyhow!(
"no harness state for {}, and several harnesses are up:\n{listed}\n\
cd into the checkout whose harness you mean, then re-run.",
cwd.display()
))
}
}
}
fn state_owner(path: &Path) -> String {
let owner = std::fs::read(path)
.ok()
.and_then(|bytes| serde_json::from_slice::<HarnessState>(&bytes).ok())
.map(|state| state.monorepo_path);
match owner {
Some(p) if !p.as_os_str().is_empty() => p.display().to_string(),
_ => path.display().to_string(),
}
}
impl HarnessState {
pub fn load() -> Result<Self> {
let path = resolve_state_path()?;
let bytes = std::fs::read(&path)
.with_context(|| format!("read harness state at {}", path.display()))?;
serde_json::from_slice(&bytes).context("parse harness-state.json")
}
pub fn load_for(monorepo_path: &Path) -> Result<Self> {
let path = state_path(monorepo_path)?;
let bytes = std::fs::read(&path)
.with_context(|| format!("read harness state at {}", path.display()))?;
serde_json::from_slice(&bytes).context("parse harness-state.json")
}
pub fn save(&self) -> Result<PathBuf> {
let path = state_path(&self.monorepo_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_path_is_per_checkout() {
let a = state_path(Path::new("/tmp/checkout-a")).unwrap();
let b = state_path(Path::new("/tmp/checkout-b")).unwrap();
assert_ne!(a, b);
assert_eq!(a.file_name().unwrap(), "harness-state.json");
assert_eq!(a.parent().unwrap().parent(), b.parent().unwrap().parent());
assert_eq!(a, state_path(Path::new("/tmp/checkout-a")).unwrap());
}
#[test]
fn state_serializes_and_round_trips() {
let state = HarnessState {
created_at: "2026-07-01T00:00:00Z".into(),
monorepo_path: "/tmp/monorepo".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),
client_node_ports: None,
}],
channel: None,
supervisor_pid: Some(9999),
client_node_instance: Some("alice".into()),
operation_mode: true,
pwa_dist_built: Some(true),
};
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));
assert_eq!(back.client_node_instance, Some("alice".into()));
assert!(back.operation_mode);
assert_eq!(back.pwa_dist_built, Some(true));
}
#[test]
fn missing_pwa_dist_built_defaults_to_none_for_legacy_state() {
let legacy_json = r#"{
"created_at": "2026-07-01T00:00:00Z",
"monorepo_path": "/tmp/monorepo",
"bitcoind": {
"mode": "docker",
"rpc_url": "http://127.0.0.1:18443",
"rpc_user": "polaruser",
"container_id": null
},
"instances": [],
"channel": null
}"#;
let state: HarnessState = serde_json::from_str(legacy_json).unwrap();
assert_eq!(state.pwa_dist_built, None);
}
}