node-app-build 6.3.0

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! `node-app restart` — ask a RUNNING `node-app dev` session to restart its
//! platform daemon(s), optionally rebuilding `node-server` first.
//!
//! Equivalent to the dev TUI's `r` key (`--build` ≈ `b` → system scope), but
//! drivable from a second terminal, a script, or an AI agent. Transport is a
//! control file the dev orchestrator polls every 200ms:
//!
//!   <env_dir>/control/request.json      written here, consumed by dev
//!   <env_dir>/control/last-result.json  outcome, matched by timestamp
//!
//! The daemon keeps its data (DB, LDK state) — this is a process restart,
//! not a reset.

use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use anyhow::{bail, Context, Result};

use super::dev::host::monorepo::monorepo_dev_dir;

/// How long to wait for the dev orchestrator to pick the request up. It
/// polls every 200ms once idle, but on a fresh boot the main thread can be
/// busy for minutes (staging + sideloading every dep before it enters the
/// poll loop), so this is deliberately generous.
const PICKUP_TIMEOUT: Duration = Duration::from_secs(180);

pub fn run(project_path: &Path, instances: &[String], build: bool, timeout_secs: u64) -> Result<()> {
    let project_path = project_path
        .canonicalize()
        .with_context(|| format!("canonicalize {}", project_path.display()))?;
    let instances: Vec<String> = if instances.is_empty() {
        vec!["alice".to_string()]
    } else {
        instances.to_vec()
    };

    for instance in &instances {
        let env_dir = env_dir_for(&project_path, instance)?;
        if !env_dir.is_dir() {
            bail!(
                "no dev environment for instance '{instance}' at {} — has `node-app dev` ever run here?",
                env_dir.display()
            );
        }

        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_millis() as u64)
            .unwrap_or(0);
        let control_dir = env_dir.join("control");
        fs::create_dir_all(&control_dir)
            .with_context(|| format!("mkdir -p {}", control_dir.display()))?;
        let request_path = control_dir.join("request.json");
        let body = serde_json::json!({
            "action": "restart",
            "build": if build { serde_json::Value::from("system") } else { serde_json::Value::Null },
            "ts": ts,
        });
        let tmp = control_dir.join("request.json.tmp");
        fs::write(&tmp, body.to_string()).with_context(|| format!("write {}", tmp.display()))?;
        fs::rename(&tmp, &request_path)
            .with_context(|| format!("rename → {}", request_path.display()))?;

        println!(
            "→ requested restart of '{instance}'{}",
            if build { " with node-server rebuild" } else { "" }
        );

        // 1. Wait for pickup (request file consumed by the orchestrator).
        let started = Instant::now();
        loop {
            if !request_path.exists() {
                break;
            }
            if started.elapsed() > PICKUP_TIMEOUT {
                let _ = fs::remove_file(&request_path);
                bail!(
                    "dev session for '{instance}' didn't pick the request up within {}s. \
                     Either no `node-app dev` session is running, or it is still booting \
                     (staging + sideloading deps) — wait for it to finish and retry.",
                    PICKUP_TIMEOUT.as_secs()
                );
            }
            std::thread::sleep(Duration::from_millis(200));
        }

        // 2. Wait for the matching result.
        let result_path = control_dir.join("last-result.json");
        let deadline = Instant::now() + Duration::from_secs(timeout_secs);
        loop {
            if let Ok(text) = fs::read_to_string(&result_path) {
                if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
                    if v.get("ts").and_then(|t| t.as_u64()) == Some(ts) {
                        let ok = v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false);
                        let msg = v
                            .get("message")
                            .and_then(|m| m.as_str())
                            .unwrap_or("(no message)");
                        if ok {
                            println!(
                                "✓ '{instance}' restarted ({:.1}s): {msg}",
                                started.elapsed().as_secs_f32()
                            );
                            break;
                        }
                        bail!("restart of '{instance}' failed: {msg}");
                    }
                }
            }
            if Instant::now() > deadline {
                bail!(
                    "no result from '{instance}' within {timeout_secs}s — check the dev session logs \
                     (the restart may still be in progress; a --build of a cold workspace can take longer: \
                     retry with --timeout 1200)"
                );
            }
            std::thread::sleep(Duration::from_millis(300));
        }
    }
    Ok(())
}

/// The per-instance dev environment dir (parent of the staged-apps dev dir):
/// holds control.sock, daemon.pid, daemon.log, and now control/.
fn env_dir_for(project_path: &Path, instance: &str) -> Result<PathBuf> {
    let dev_dir = monorepo_dev_dir(project_path, instance, None)?;
    dev_dir
        .parent()
        .map(|p| p.to_path_buf())
        .ok_or_else(|| anyhow::anyhow!("dev dir {} has no parent", dev_dir.display()))
}

// ── Tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn env_dir_is_parent_of_dev_apps_dir() {
        let tmp = tempfile::TempDir::new().unwrap();
        let env_dir = env_dir_for(tmp.path(), "alice").unwrap();
        let dev_dir = monorepo_dev_dir(tmp.path(), "alice", None).unwrap();
        assert_eq!(dev_dir.parent().unwrap(), env_dir);
        assert_eq!(dev_dir, env_dir.join("dev-apps"));
    }

    #[test]
    fn env_dir_differs_per_instance() {
        let tmp = tempfile::TempDir::new().unwrap();
        let alice = env_dir_for(tmp.path(), "alice").unwrap();
        let bob = env_dir_for(tmp.path(), "bob").unwrap();
        assert_ne!(alice, bob);
    }
}