fix(#1164,#1171): harness writes status file, c0re just rescans
This commit is contained in:
parent
a9ce8a945f
commit
049ae47191
5 changed files with 75 additions and 26 deletions
|
|
@ -102,6 +102,51 @@ impl From<hive_sh4re::Response> for SocketReply {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write (or remove) the status file in the agent's own `state/` directory.
|
||||||
|
/// Called by both `AgentServer::set_status` and `ManagerServer::set_status`
|
||||||
|
/// before dispatching the wire `SetStatus` request (which only triggers a
|
||||||
|
/// dashboard rescan on the host side — file I/O moved here because the
|
||||||
|
/// harness runs as the agent user and has write access to `state/`, whereas
|
||||||
|
/// hive-c0re's `hive-core` user does not after the privsep migration).
|
||||||
|
///
|
||||||
|
/// Mirrors the validation in `hive-c0re::limits::check_status_text` so
|
||||||
|
/// the file is never written with text the server would later reject (which
|
||||||
|
/// would leave a stale invalid entry on disk).
|
||||||
|
fn write_status_file(text: &str) -> Result<(), String> {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
if trimmed.contains('\n') || trimmed.contains('\r') {
|
||||||
|
return Err(
|
||||||
|
"set_status text must be a single line — write multi-line context to \
|
||||||
|
a file under your state/ dir and reference that path from the chip instead"
|
||||||
|
.to_owned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 200 chars mirrors STATUS_MAX_CHARS in hive-c0re/src/limits.rs.
|
||||||
|
// Keep in sync if that constant changes.
|
||||||
|
const STATUS_MAX_CHARS: usize = 200;
|
||||||
|
let len = trimmed.chars().count();
|
||||||
|
if len > STATUS_MAX_CHARS {
|
||||||
|
return Err(format!(
|
||||||
|
"set_status text too long ({len} chars, max {STATUS_MAX_CHARS}); trim to a short summary"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let path = crate::paths::state_dir().join("hyperhive-status");
|
||||||
|
let result = if trimmed.is_empty() {
|
||||||
|
std::fs::remove_file(&path).or_else(|e| {
|
||||||
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
std::fs::write(&path, format!("{trimmed}\n"))
|
||||||
|
};
|
||||||
|
result.map_err(|e| format!("set_status write failed: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Format helper for "send-like" tools (anything that expects an `Ok`).
|
/// Format helper for "send-like" tools (anything that expects an `Ok`).
|
||||||
/// `tool` and `ok_msg` only appear in the result string; they don't change
|
/// `tool` and `ok_msg` only appear in the result string; they don't change
|
||||||
/// behavior.
|
/// behavior.
|
||||||
|
|
@ -763,6 +808,9 @@ impl AgentServer {
|
||||||
)]
|
)]
|
||||||
async fn set_status(&self, Parameters(args): Parameters<SetStatusArgs>) -> String {
|
async fn set_status(&self, Parameters(args): Parameters<SetStatusArgs>) -> String {
|
||||||
run_tool_envelope("set_status", args.text.clone(), async move {
|
run_tool_envelope("set_status", args.text.clone(), async move {
|
||||||
|
if let Err(e) = write_status_file(&args.text) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
let (resp, retries) = self
|
let (resp, retries) = self
|
||||||
.dispatch(hive_sh4re::AgentRequest::SetStatus { text: args.text })
|
.dispatch(hive_sh4re::AgentRequest::SetStatus { text: args.text })
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -1821,6 +1869,9 @@ impl ManagerServer {
|
||||||
)]
|
)]
|
||||||
async fn set_status(&self, Parameters(args): Parameters<SetStatusArgs>) -> String {
|
async fn set_status(&self, Parameters(args): Parameters<SetStatusArgs>) -> String {
|
||||||
run_tool_envelope("set_status", args.text.clone(), async move {
|
run_tool_envelope("set_status", args.text.clone(), async move {
|
||||||
|
if let Err(e) = write_status_file(&args.text) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
let (resp, retries) = self
|
let (resp, retries) = self
|
||||||
.dispatch(hive_sh4re::ManagerRequest::SetStatus { text: args.text })
|
.dispatch(hive_sh4re::ManagerRequest::SetStatus { text: args.text })
|
||||||
.await;
|
.await;
|
||||||
|
|
|
||||||
|
|
@ -239,29 +239,13 @@ pub(crate) async fn dispatch_shared(
|
||||||
if let Err(message) = crate::limits::check_status_text(text) {
|
if let Err(message) = crate::limits::check_status_text(text) {
|
||||||
return Some(hive_sh4re::Response::Err { message });
|
return Some(hive_sh4re::Response::Err { message });
|
||||||
}
|
}
|
||||||
let path =
|
// The harness writes the status file to its own `state/` dir
|
||||||
crate::coordinator::Coordinator::agent_notes_dir(agent).join("hyperhive-status");
|
// before sending this request (it runs as the agent user, so
|
||||||
let result = if text.trim().is_empty() {
|
// it has write access). We just trigger a dashboard rescan so
|
||||||
std::fs::remove_file(&path).or_else(|e| {
|
// the new value is reflected immediately.
|
||||||
if e.kind() == std::io::ErrorKind::NotFound {
|
let coord2 = Arc::clone(coord);
|
||||||
Ok(())
|
tokio::spawn(async move { coord2.rescan_containers_and_emit().await });
|
||||||
} else {
|
hive_sh4re::Response::Ok
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
std::fs::write(&path, format!("{}\n", text.trim()))
|
|
||||||
};
|
|
||||||
match result {
|
|
||||||
Ok(()) => {
|
|
||||||
let coord2 = Arc::clone(coord);
|
|
||||||
tokio::spawn(async move { coord2.rescan_containers_and_emit().await });
|
|
||||||
hive_sh4re::Response::Ok
|
|
||||||
}
|
|
||||||
Err(e) => hive_sh4re::Response::Err {
|
|
||||||
message: format!("set_status write failed: {e}"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
hive_sh4re::Request::GetAgentMeta { name } => {
|
hive_sh4re::Request::GetAgentMeta { name } => {
|
||||||
let target = name.as_deref().unwrap_or(agent);
|
let target = name.as_deref().unwrap_or(agent);
|
||||||
|
|
|
||||||
|
|
@ -303,7 +303,17 @@ fn auth_failed_sentinel(name: &str) -> bool {
|
||||||
pub fn read_agent_status(name: &str) -> (Option<String>, Option<i64>) {
|
pub fn read_agent_status(name: &str) -> (Option<String>, Option<i64>) {
|
||||||
let path = Coordinator::agent_notes_dir(name).join("hyperhive-status");
|
let path = Coordinator::agent_notes_dir(name).join("hyperhive-status");
|
||||||
let meta = std::fs::metadata(&path).ok();
|
let meta = std::fs::metadata(&path).ok();
|
||||||
let s = std::fs::read_to_string(&path).ok();
|
// Read at most STATUS_MAX_CHARS * 4 + 2 bytes: 4 is the max UTF-8 byte
|
||||||
|
// width per char, +2 for the trailing newline. Guards against a
|
||||||
|
// pathologically large file written outside the harness validation path.
|
||||||
|
let s = {
|
||||||
|
use std::io::Read as _;
|
||||||
|
std::fs::File::open(&path).ok().and_then(|f| {
|
||||||
|
let cap = (crate::limits::STATUS_MAX_CHARS * 4 + 2) as u64;
|
||||||
|
let mut buf = String::new();
|
||||||
|
f.take(cap).read_to_string(&mut buf).ok().map(|_| buf)
|
||||||
|
})
|
||||||
|
};
|
||||||
let text = s
|
let text = s
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,9 @@ pub fn check_size(label: &str, body: &str) -> Result<(), String> {
|
||||||
/// payload. Cap at 200 chars to fit the chip plus a little
|
/// payload. Cap at 200 chars to fit the chip plus a little
|
||||||
/// descriptive padding without forcing the operator to read a
|
/// descriptive padding without forcing the operator to read a
|
||||||
/// scrolling chunk.
|
/// scrolling chunk.
|
||||||
|
/// NOTE: `hive-ag3nt/src/mcp.rs::write_status_file` mirrors this constant
|
||||||
|
/// client-side so invalid text is caught before the file is written.
|
||||||
|
/// Keep in sync if this value changes.
|
||||||
pub const STATUS_MAX_CHARS: usize = 200;
|
pub const STATUS_MAX_CHARS: usize = 200;
|
||||||
|
|
||||||
/// Validate a `set_status` payload. Single-line + bounded so
|
/// Validate a `set_status` payload. Single-line + bounded so
|
||||||
|
|
|
||||||
|
|
@ -441,8 +441,9 @@ pub enum Request {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
agent: Option<String>,
|
agent: Option<String>,
|
||||||
},
|
},
|
||||||
/// Set a free-text status string visible on the dashboard. Persisted
|
/// Set a free-text status string visible on the dashboard. The harness
|
||||||
/// to `{state_dir}/hyperhive-status` so it survives harness restarts.
|
/// writes `{state_dir}/hyperhive-status` locally before sending this
|
||||||
|
/// request; hive-c0re just triggers a dashboard rescan on receipt.
|
||||||
/// Pass an empty string to clear the status.
|
/// Pass an empty string to clear the status.
|
||||||
SetStatus { text: String },
|
SetStatus { text: String },
|
||||||
/// Fetch identity + status for an agent. `name = None` =
|
/// Fetch identity + status for an agent. `name = None` =
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue