hatchery-node 0.2.66

Hatchery node: the complete node kit over the gate4agent transport (PTY/inline sessions, file browser, local git, worktrees, mailbox, browser station, session restore)
Documentation
//! Node-local session registry fields that HQ also tracks: origin, parent,
//! gone, last_line. Survives in enroll state; HQ learns gone when the node
//! omits a session after resume, or when status is exited/failed (HQ marks
//! tombstone). Origin/parent are set from spawn options when present.

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Meta {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub origin: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_session_id: Option<String>,
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub gone: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_line: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provider: Option<String>,
    #[serde(default)]
    pub updated_ms: u64,
}

static LOCK: Mutex<()> = Mutex::new(());

pub fn path(state_dir: &Path) -> PathBuf {
    state_dir.join("session-meta.json")
}

fn load_map(file: &Path) -> BTreeMap<String, Meta> {
    std::fs::read(file)
        .ok()
        .and_then(|b| serde_json::from_slice(&b).ok())
        .unwrap_or_default()
}

fn save_map(file: &Path, map: &BTreeMap<String, Meta>) {
    let tmp = file.with_extension("json.tmp");
    if let Ok(b) = serde_json::to_vec_pretty(map) {
        let _ = std::fs::write(&tmp, b).and_then(|_| std::fs::rename(&tmp, file));
    }
}

fn key(instance_id: u64) -> String {
    instance_id.to_string()
}

fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

pub fn upsert(state_dir: &Path, instance_id: u64, patch: Meta) {
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let file = path(state_dir);
    let mut m = load_map(&file);
    let e = m.entry(key(instance_id)).or_default();
    if let Some(o) = patch.origin {
        e.origin = Some(o);
    }
    if let Some(p) = patch.parent_session_id {
        e.parent_session_id = Some(p);
    }
    if patch.gone {
        e.gone = true;
    }
    if let Some(l) = patch.last_line {
        e.last_line = Some(l);
    }
    if let Some(p) = patch.provider {
        e.provider = Some(p);
    }
    e.updated_ms = now_ms();
    save_map(&file, &m);
}

pub fn mark_gone(state_dir: &Path, instance_id: u64) {
    upsert(
        state_dir,
        instance_id,
        Meta {
            gone: true,
            updated_ms: now_ms(),
            ..Meta::default()
        },
    );
}

pub fn revive(state_dir: &Path, instance_id: u64) {
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let file = path(state_dir);
    let mut m = load_map(&file);
    if let Some(e) = m.get_mut(&key(instance_id)) {
        e.gone = false;
        e.updated_ms = now_ms();
        save_map(&file, &m);
    }
}

pub fn get(state_dir: &Path, instance_id: u64) -> Option<Meta> {
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    load_map(&path(state_dir)).remove(&key(instance_id))
}

/// Instance ids the node has marked gone (still in the journal for HQ sync).
pub fn gone_ids(state_dir: &Path) -> Vec<u64> {
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    load_map(&path(state_dir))
        .into_iter()
        .filter(|(_, v)| v.gone)
        .filter_map(|(k, _)| k.parse().ok())
        .collect()
}

pub fn set_last_line(state_dir: &Path, instance_id: u64, line: &str) {
    let line = line.trim();
    if line.is_empty() {
        return;
    }
    let clipped: String = line.chars().take(160).collect();
    upsert(
        state_dir,
        instance_id,
        Meta {
            last_line: Some(clipped),
            ..Meta::default()
        },
    );
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{SystemTime, UNIX_EPOCH};

    #[test]
    fn gone_and_origin_roundtrip() {
        let dir = std::env::temp_dir().join(format!(
            "session-meta-test-{}",
            SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
        ));
        let _ = std::fs::create_dir_all(&dir);
        upsert(
            &dir,
            7,
            Meta {
                origin: Some("human".into()),
                parent_session_id: Some("claude#3".into()),
                provider: Some("claude".into()),
                ..Meta::default()
            },
        );
        let m = get(&dir, 7).unwrap();
        assert_eq!(m.origin.as_deref(), Some("human"));
        assert_eq!(m.parent_session_id.as_deref(), Some("claude#3"));
        mark_gone(&dir, 7);
        assert!(get(&dir, 7).unwrap().gone);
        assert!(gone_ids(&dir).contains(&7));
        revive(&dir, 7);
        assert!(!get(&dir, 7).unwrap().gone);
        let _ = std::fs::remove_dir_all(&dir);
    }
}