use std::time::Duration;
use crate::{
bindings::host::murmur::tool::run::{Status, ToolResult},
detached::{CommandSnapshot, CommandState, WORK_ID_PREFIX},
shell::MAX_SHELL_OUTPUT_BYTES,
};
pub const BACKGROUND_COMMAND_TOOL: &str = "background-command";
pub const MAX_WAIT_SECS: u64 = 600;
pub(crate) const STOP_TERM_GRACE: Duration = Duration::from_secs(2);
pub(crate) const STOP_KILL_WAIT: Duration = Duration::from_secs(3);
pub const INPUT_SCHEMA: &str = r#"{"type":"object","properties":{"operation":{"type":"string","enum":["status","wait","stop"]},"work_id":{"type":"string"},"wait_secs":{"type":"integer","minimum":0}},"required":["operation","work_id"]}"#;
pub fn description() -> String {
format!(
"Reports on, waits for, or stops a shell command that was moved to the background, named \
by the work id ({WORK_ID_PREFIX}…) its shell call returned. `operation` is `status`, \
`wait` or `stop`. `status` returns at once. `wait` returns once the command exits or \
after `wait_secs` seconds, whichever is first; `wait_secs` defaults to {MAX_WAIT_SECS}, \
which is also the most it can be. `stop` sends SIGTERM to the command and every process \
in its process group, SIGKILL {term} seconds later to any still running, and returns \
once the command has exited; a process that left the group is not reached and ends \
with the session. Each operation returns the command's state (running, exited or \
stopped), its exit code once it has one, the seconds since it started, and the last \
{MAX_SHELL_OUTPUT_BYTES} bytes of its combined stdout and stderr, with the path of the \
file in the session's records that holds all of it. A work id is valid for the rest of \
the session that issued it; a command still running at the session's end is killed.",
term = STOP_TERM_GRACE.as_secs(),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Operation {
Status,
Wait,
Stop,
}
impl Operation {
const ALL: [Self; 3] = [Self::Status, Self::Wait, Self::Stop];
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Status => "status",
Self::Wait => "wait",
Self::Stop => "stop",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Request {
pub operation: Operation,
pub work_id: String,
pub wait_secs: Option<u64>,
}
fn accepted_operations() -> String {
Operation::ALL
.map(|operation| format!("`{}`", operation.as_str()))
.join(", ")
}
pub(crate) fn parse_request(
args: &serde_json::Map<String, serde_json::Value>,
) -> Result<Request, String> {
let operation = match args.get("operation") {
None | Some(serde_json::Value::Null) => {
return Err(format!(
"'{BACKGROUND_COMMAND_TOOL}' requires `operation`, one of {}",
accepted_operations()
))
}
Some(value) => Operation::ALL
.into_iter()
.find(|operation| value.as_str() == Some(operation.as_str()))
.ok_or_else(|| {
format!(
"'{BACKGROUND_COMMAND_TOOL}' has no operation {value}; `operation` is one of {}",
accepted_operations()
)
})?,
};
let work_id = match args.get("work_id") {
None | Some(serde_json::Value::Null) => {
return Err(format!(
"'{BACKGROUND_COMMAND_TOOL}' requires `work_id`, the {WORK_ID_PREFIX}<hex> id a \
shell call returned"
))
}
Some(serde_json::Value::String(work_id)) => work_id.clone(),
Some(value) => {
return Err(format!(
"'{BACKGROUND_COMMAND_TOOL}' got `work_id` {value}; a work id is a string of the \
form {WORK_ID_PREFIX}<hex>"
))
}
};
if !is_work_id(&work_id) {
return Err(format!(
"`{work_id}` is not a work id: a work id has the form {WORK_ID_PREFIX}<hex>"
));
}
let wait_secs = match args.get("wait_secs") {
None | Some(serde_json::Value::Null) => None,
Some(value) => Some(value.as_u64().ok_or_else(|| {
format!(
"'{BACKGROUND_COMMAND_TOOL}' got `wait_secs` {value}; `wait_secs` is a whole \
number of seconds, 0 or more"
)
})?),
};
Ok(Request {
operation,
work_id,
wait_secs,
})
}
fn is_work_id(text: &str) -> bool {
text.strip_prefix(WORK_ID_PREFIX).is_some_and(|hex| {
!hex.is_empty()
&& hex
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
})
}
pub(crate) fn unknown_work_id(work_id: &str) -> String {
format!("no background command with work id `{work_id}` in this session")
}
pub(crate) fn not_available() -> String {
format!("'{BACKGROUND_COMMAND_TOOL}': background commands are not available in this session")
}
pub fn effective_wait(wait_secs: Option<u64>) -> Duration {
Duration::from_secs(wait_secs.unwrap_or(MAX_WAIT_SECS).min(MAX_WAIT_SECS))
}
pub(crate) fn render(
snapshot: &CommandSnapshot,
waited: Option<Duration>,
) -> (ToolResult, Option<String>) {
let mut lines = vec![
format!("work_id: {}", snapshot.work_id),
if snapshot.command.is_empty() {
format!("command: {}", snapshot.binary)
} else {
format!("command: {} {}", snapshot.binary, snapshot.command)
},
format!("state: {}", snapshot.state.as_str()),
format!("elapsed_secs: {}", snapshot.elapsed_ms / 1000),
];
if let Some(waited) = waited {
lines.push(format!("waited_secs: {}", waited.as_secs()));
}
let mut metadata = vec![
("work_id".to_string(), snapshot.work_id.clone()),
("state".to_string(), snapshot.state.as_str().to_string()),
];
let mut notes = Vec::new();
if let Some(completion) = &snapshot.finished {
lines.push(format!("exit_code: {}", completion.exit_code));
lines.push(format!("status: {}", completion.status()));
if let Some(error) = &completion.error {
lines.push(format!("wait_error: {error}"));
}
metadata.push(("exit_code".to_string(), completion.exit_code.to_string()));
notes.extend(
completion
.resource_limit
.as_deref()
.map(crate::resources::resource_limit_line),
);
notes.extend(completion.output_holders.note());
}
let truncated = snapshot.output_bytes > snapshot.tail.len() as u64;
lines.push(format!("output: {}", snapshot.output_path));
lines.push(format!("output_bytes: {}", snapshot.output_bytes));
lines.push(if truncated {
format!("output_shown: last {MAX_SHELL_OUTPUT_BYTES} bytes")
} else {
"output_shown: all".to_string()
});
let shown = if truncated {
from_char_boundary(&snapshot.tail)
} else {
&snapshot.tail
};
let mut data = lines.join("\n");
if !shown.is_empty() {
data.push_str("\n\n");
data.push_str(&String::from_utf8_lossy(shown));
}
let summary = match &snapshot.finished {
Some(completion) if snapshot.state != CommandState::Running => format!(
"{} {}, exit code {}",
snapshot.work_id,
snapshot.state.as_str(),
completion.exit_code
),
_ => format!("{} {}", snapshot.work_id, snapshot.state.as_str()),
};
let result = ToolResult {
status: Status::Passed,
summary: Some(summary),
data: Some(data),
data_path: None,
truncated,
metadata,
};
(result, (!notes.is_empty()).then(|| notes.join("\n")))
}
fn from_char_boundary(tail: &[u8]) -> &[u8] {
let skip = tail
.iter()
.take(3)
.take_while(|byte| (**byte & 0b1100_0000) == 0b1000_0000)
.count();
&tail[skip..]
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn the_description_states_operations_and_limits_only() {
let description = description();
for fact in [
"`status`",
"`wait`",
"`stop`",
"wrk_",
"600",
"16384",
"SIGTERM",
"SIGKILL",
"2 seconds",
"process group",
"running, exited or stopped",
"killed",
] {
assert!(description.contains(fact), "{fact} missing: {description}");
}
let lower = description.to_lowercase();
for advice in ["when", "should", "call this", "use this"] {
assert!(!lower.contains(advice), "{advice}: {description}");
}
}
#[test]
fn the_input_schema_names_the_three_operations() {
let schema: serde_json::Value = serde_json::from_str(INPUT_SCHEMA).unwrap();
assert_eq!(
schema["properties"]["operation"]["enum"],
json!(["status", "wait", "stop"])
);
assert_eq!(schema["required"], json!(["operation", "work_id"]));
assert_eq!(schema["properties"]["wait_secs"]["minimum"], json!(0));
}
#[test]
fn wait_secs_is_capped_at_the_runtime_limit() {
assert_eq!(effective_wait(None), Duration::from_secs(600));
assert_eq!(effective_wait(Some(0)), Duration::ZERO);
assert_eq!(effective_wait(Some(30)), Duration::from_secs(30));
assert_eq!(effective_wait(Some(100_000)), Duration::from_secs(600));
}
fn parse(args: serde_json::Value) -> Result<Request, String> {
parse_request(args.as_object().expect("a JSON object"))
}
#[test]
fn a_request_is_parsed_and_each_refusal_names_its_value() {
assert_eq!(
parse(json!({"operation": "wait", "work_id": "wrk_0a1b", "wait_secs": 5})),
Ok(Request {
operation: Operation::Wait,
work_id: "wrk_0a1b".to_string(),
wait_secs: Some(5),
})
);
let refused = |args: serde_json::Value| parse(args).unwrap_err();
let malformed = refused(json!({"operation": "status", "work_id": "abc"}));
assert!(
malformed.contains("`abc`") && malformed.contains("wrk_<hex>"),
"{malformed}"
);
for not_hex in ["wrk_", "wrk_0A1B", "wrk_xyz", "WRK_0a1b"] {
let refusal = refused(json!({"operation": "status", "work_id": not_hex}));
assert!(refusal.contains(not_hex), "{refusal}");
}
let missing = refused(json!({"operation": "status"}));
assert!(missing.contains("`work_id`"), "{missing}");
let restart = refused(json!({"operation": "restart", "work_id": "wrk_0a1b"}));
assert!(
restart.contains("\"restart\"") && restart.contains("`status`, `wait`, `stop`"),
"{restart}"
);
let negative =
refused(json!({"operation": "wait", "work_id": "wrk_0a1b", "wait_secs": -1}));
assert!(negative.contains("-1"), "{negative}");
}
fn snapshot(tail: &[u8], output_bytes: u64) -> CommandSnapshot {
CommandSnapshot {
work_id: "wrk_0a1b".to_string(),
binary: "/usr/bin/bash".to_string(),
command: "make test".to_string(),
state: CommandState::Running,
elapsed_ms: 12_345,
finished: None,
output_path: "/records/logs/wrk_0a1b.log".to_string(),
output_bytes,
tail: tail.to_vec(),
}
}
#[test]
fn a_running_command_renders_without_exit_lines() {
let (result, note) = render(&snapshot(b"tick 1\n", 7), None);
assert_eq!(
result.data.as_deref(),
Some(
"work_id: wrk_0a1b\ncommand: /usr/bin/bash make test\nstate: running\n\
elapsed_secs: 12\noutput: /records/logs/wrk_0a1b.log\noutput_bytes: 7\n\
output_shown: all\n\ntick 1\n"
)
);
assert!(note.is_none());
assert!(!result.truncated);
assert!(matches!(result.status, Status::Passed));
}
#[test]
fn a_cut_tail_starts_on_a_character() {
let mut tail = "é".as_bytes()[1..].to_vec();
tail.extend_from_slice(b"rest");
let (result, _) = render(
&snapshot(&tail, 100_000),
Some(Duration::from_millis(1_900)),
);
let data = result.data.unwrap();
assert!(data.contains("waited_secs: 1\n"), "{data}");
assert!(data.contains("output_shown: last 16384 bytes"), "{data}");
assert!(data.ends_with("\n\nrest"), "{data}");
assert!(result.truncated);
}
}