use std::time::Duration;
use anyhow::{Result, anyhow};
use heddle_core::daemon_plan::{
daemon_health_failed_kind, daemon_materialized_thread_line, daemon_materialized_threads_header,
daemon_not_running_status_token, daemon_running_status_token, daemon_short_tree,
daemon_shutdown_refused_kind, daemon_status_not_running_line, daemon_status_running_line,
daemon_stop_not_running_line, daemon_stop_status_token, daemon_stop_stopped_line,
daemon_unexpected_response_kind,
};
use repo::daemon::{
MountDaemonRequest, MountDaemonResponse, load_endpoint, mount_daemon_endpoint_path, pid_alive,
};
use serde::Serialize;
use super::client::{rpc, sweep_stale_mounts};
use crate::cli::{
Cli, commands::advice::RecoveryAdvice, render::write_json_stdout, should_output_json,
};
#[derive(Debug, Serialize)]
struct DaemonStopOutput {
output_kind: &'static str,
action: &'static str,
status: &'static str,
}
#[derive(Debug, Serialize)]
struct DaemonStatusOutput {
status: &'static str,
running: bool,
endpoint_path: String,
ok: bool,
version: Option<u32>,
uptime_s: Option<u64>,
mount_count: usize,
materialized_count: usize,
materialized_threads: Vec<MaterializedThreadStatus>,
}
#[derive(Debug, Serialize)]
struct MaterializedThreadStatus {
thread: String,
state: String,
files: usize,
tree: String,
}
#[cfg(all(target_os = "linux", feature = "mount"))]
pub fn cmd_daemon_serve(cli: &Cli) -> Result<()> {
let repo_root = resolve_repo_root(cli)?;
super::server::run_mount_daemon(&repo_root)
}
#[cfg(not(all(target_os = "linux", feature = "mount")))]
pub fn cmd_daemon_serve(_cli: &Cli) -> Result<()> {
Err(
crate::cli::commands::mount_lifecycle::virtualized_unsupported_error()
.context("heddle daemon serve"),
)
}
pub fn cmd_daemon_status(cli: &Cli) -> Result<()> {
let repo_root = resolve_repo_root(cli)?;
let response = rpc(&repo_root, &MountDaemonRequest::Health {}, false)?;
let heddle_dir = resolve_heddle_dir(cli).unwrap_or_else(|_| repo_root.join(".heddle"));
let materialized =
repo::thread_manifest::list_thread_manifests(&heddle_dir).unwrap_or_default();
let materialized_threads = materialized
.iter()
.map(|manifest| {
let tree_full = manifest.tree_hash.to_string();
MaterializedThreadStatus {
thread: manifest.thread.clone(),
state: manifest.state_id.to_string(),
files: manifest.file_count,
tree: daemon_short_tree(&tree_full).to_string(),
}
})
.collect::<Vec<_>>();
let endpoint_path = mount_daemon_endpoint_path(&repo_root).display().to_string();
let json = should_output_json(cli, None);
match response {
Some(MountDaemonResponse::Health {
version,
ok,
uptime_s,
mount_count,
}) => {
if json {
let output = DaemonStatusOutput {
status: daemon_running_status_token(),
running: true,
endpoint_path,
ok,
version: Some(version),
uptime_s: Some(uptime_s),
mount_count,
materialized_count: materialized_threads.len(),
materialized_threads,
};
crate::cli::render::write_json_stdout(&output)?;
return Ok(());
} else {
println!(
"{}",
daemon_status_running_line(
ok,
version,
uptime_s,
mount_count,
materialized.len(),
)
);
}
}
Some(MountDaemonResponse::Error { code, message, .. }) => {
return Err(anyhow!(daemon_response_refusal(
daemon_health_failed_kind(),
format!("daemon health failed: [{code}] {message}"),
format!("daemon returned error code {code}: {message}"),
"heddle daemon status",
)));
}
Some(other) => {
return Err(anyhow!(daemon_response_refusal(
daemon_unexpected_response_kind(),
format!("unexpected daemon response: {other:?}"),
format!(
"daemon returned a response variant that `status` cannot interpret: {other:?}"
),
"heddle daemon status",
)));
}
None => {
if json {
let output = DaemonStatusOutput {
status: daemon_not_running_status_token(),
running: false,
endpoint_path,
ok: false,
version: None,
uptime_s: None,
mount_count: 0,
materialized_count: materialized_threads.len(),
materialized_threads,
};
crate::cli::render::write_json_stdout(&output)?;
return Ok(());
} else {
println!(
"{}",
daemon_status_not_running_line(
&mount_daemon_endpoint_path(&repo_root).display().to_string(),
materialized.len(),
)
);
}
}
}
if !materialized.is_empty() {
println!("{}", daemon_materialized_threads_header());
for s in &materialized {
let tree_full = s.tree_hash.to_string();
println!(
"{}",
daemon_materialized_thread_line(
&s.thread,
&s.state_id.to_string(),
s.file_count,
daemon_short_tree(&tree_full),
)
);
}
}
Ok(())
}
pub fn cmd_daemon_stop(cli: &Cli) -> Result<()> {
let repo_root = resolve_repo_root(cli)?;
let json = should_output_json(cli, None);
let endpoint_path = mount_daemon_endpoint_path(&repo_root);
let recorded_pid = load_endpoint(&endpoint_path).ok().and_then(|e| e.pid);
match rpc(&repo_root, &MountDaemonRequest::Shutdown {}, false)? {
Some(MountDaemonResponse::Shutdown { ok: true, .. }) => {}
Some(MountDaemonResponse::Error { code, message, .. }) => {
return Err(anyhow!(daemon_response_refusal(
daemon_shutdown_refused_kind(),
format!("daemon refused shutdown: [{code}] {message}"),
format!("daemon returned error code {code}: {message}"),
"heddle daemon status",
)));
}
Some(other) => {
return Err(anyhow!(daemon_response_refusal(
daemon_unexpected_response_kind(),
format!("unexpected daemon response: {other:?}"),
format!(
"daemon returned a response variant that `stop` cannot interpret: {other:?}"
),
"heddle daemon status",
)));
}
None => {
if json {
write_json_stdout(&DaemonStopOutput {
output_kind: "daemon_stop",
action: "daemon stop",
status: daemon_stop_status_token(false),
})?;
} else {
println!("{}", daemon_stop_not_running_line());
}
return Ok(());
}
}
for _ in 0..40 {
if !endpoint_path.exists() {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
if let Some(pid) = recorded_pid {
for _ in 0..40 {
if !pid_alive(pid) {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
}
sweep_stale_mounts(&repo_root);
if json {
write_json_stdout(&DaemonStopOutput {
output_kind: "daemon_stop",
action: "daemon stop",
status: daemon_stop_status_token(true),
})?;
} else {
println!("{}", daemon_stop_stopped_line());
}
Ok(())
}
fn daemon_response_refusal(
kind: &'static str,
error: impl Into<String>,
unsafe_condition: impl Into<String>,
primary_command: impl Into<String>,
) -> RecoveryAdvice {
let primary_command = primary_command.into();
RecoveryAdvice::safety_refusal(
kind,
error,
format!("Inspect the daemon with `{primary_command}` before retrying."),
unsafe_condition,
"continuing could accept stale mount-daemon state or act on the wrong daemon response",
"repository objects, refs, worktree files, and mount registry files were left unchanged",
primary_command.clone(),
vec![primary_command],
)
}
fn resolve_repo_root(cli: &Cli) -> Result<std::path::PathBuf> {
if let Some(root) = cli.repo.as_ref() {
return Ok(root.clone());
}
let repo = repo::Repository::open(&std::env::current_dir()?)?;
Ok(repo.root().to_path_buf())
}
fn resolve_heddle_dir(cli: &Cli) -> Result<std::path::PathBuf> {
let start = cli
.repo
.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| ".".into()));
let repo = repo::Repository::open(&start)?;
Ok(repo.heddle_dir().to_path_buf())
}
#[cfg(test)]
mod tests {
use super::daemon_response_refusal;
#[test]
fn daemon_response_refusal_carries_typed_recovery_fields() {
let advice = daemon_response_refusal(
"daemon_health_failed",
"daemon health failed: [boom] nope",
"daemon returned error code boom: nope",
"heddle daemon status",
);
assert_eq!(advice.kind, "daemon_health_failed");
assert_eq!(advice.primary_command, "heddle daemon status");
assert!(advice.hint.contains("heddle daemon status"));
assert!(advice.would_change.contains("stale mount-daemon state"));
}
}