1use std::time::Duration;
9
10use crate::{
11 bindings::host::murmur::tool::run::{Status, ToolResult},
12 detached::{CommandSnapshot, CommandState, WORK_ID_PREFIX},
13 shell::MAX_SHELL_OUTPUT_BYTES,
14};
15
16pub const BACKGROUND_COMMAND_TOOL: &str = "background-command";
18
19pub const MAX_WAIT_SECS: u64 = 600;
21
22pub(crate) const STOP_TERM_GRACE: Duration = Duration::from_secs(2);
24
25pub(crate) const STOP_KILL_WAIT: Duration = Duration::from_secs(3);
27
28pub 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"]}"#;
30
31pub fn description() -> String {
33 format!(
34 "Reports on, waits for, or stops a shell command that was moved to the background, named \
35 by the work id ({WORK_ID_PREFIX}…) its shell call returned. `operation` is `status`, \
36 `wait` or `stop`. `status` returns at once. `wait` returns once the command exits or \
37 after `wait_secs` seconds, whichever is first; `wait_secs` defaults to {MAX_WAIT_SECS}, \
38 which is also the most it can be. `stop` sends SIGTERM to the command and every process \
39 in its process group, SIGKILL {term} seconds later to any still running, and returns \
40 once the command has exited; a process that left the group is not reached and ends \
41 with the session. Each operation returns the command's state (running, exited or \
42 stopped), its exit code once it has one, the seconds since it started, and the last \
43 {MAX_SHELL_OUTPUT_BYTES} bytes of its combined stdout and stderr, with the path of the \
44 file in the session's records that holds all of it. A work id is valid for the rest of \
45 the session that issued it; a command still running at the session's end is killed.",
46 term = STOP_TERM_GRACE.as_secs(),
47 )
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub(crate) enum Operation {
53 Status,
54 Wait,
55 Stop,
56}
57
58impl Operation {
59 const ALL: [Self; 3] = [Self::Status, Self::Wait, Self::Stop];
60
61 pub(crate) fn as_str(self) -> &'static str {
62 match self {
63 Self::Status => "status",
64 Self::Wait => "wait",
65 Self::Stop => "stop",
66 }
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub(crate) struct Request {
73 pub operation: Operation,
74 pub work_id: String,
75 pub wait_secs: Option<u64>,
77}
78
79fn accepted_operations() -> String {
81 Operation::ALL
82 .map(|operation| format!("`{}`", operation.as_str()))
83 .join(", ")
84}
85
86pub(crate) fn parse_request(
88 args: &serde_json::Map<String, serde_json::Value>,
89) -> Result<Request, String> {
90 let operation = match args.get("operation") {
91 None | Some(serde_json::Value::Null) => {
92 return Err(format!(
93 "'{BACKGROUND_COMMAND_TOOL}' requires `operation`, one of {}",
94 accepted_operations()
95 ))
96 }
97 Some(value) => Operation::ALL
98 .into_iter()
99 .find(|operation| value.as_str() == Some(operation.as_str()))
100 .ok_or_else(|| {
101 format!(
102 "'{BACKGROUND_COMMAND_TOOL}' has no operation {value}; `operation` is one of {}",
103 accepted_operations()
104 )
105 })?,
106 };
107 let work_id = match args.get("work_id") {
108 None | Some(serde_json::Value::Null) => {
109 return Err(format!(
110 "'{BACKGROUND_COMMAND_TOOL}' requires `work_id`, the {WORK_ID_PREFIX}<hex> id a \
111 shell call returned"
112 ))
113 }
114 Some(serde_json::Value::String(work_id)) => work_id.clone(),
115 Some(value) => {
116 return Err(format!(
117 "'{BACKGROUND_COMMAND_TOOL}' got `work_id` {value}; a work id is a string of the \
118 form {WORK_ID_PREFIX}<hex>"
119 ))
120 }
121 };
122 if !is_work_id(&work_id) {
123 return Err(format!(
124 "`{work_id}` is not a work id: a work id has the form {WORK_ID_PREFIX}<hex>"
125 ));
126 }
127 let wait_secs = match args.get("wait_secs") {
128 None | Some(serde_json::Value::Null) => None,
129 Some(value) => Some(value.as_u64().ok_or_else(|| {
130 format!(
131 "'{BACKGROUND_COMMAND_TOOL}' got `wait_secs` {value}; `wait_secs` is a whole \
132 number of seconds, 0 or more"
133 )
134 })?),
135 };
136 Ok(Request {
137 operation,
138 work_id,
139 wait_secs,
140 })
141}
142
143fn is_work_id(text: &str) -> bool {
145 text.strip_prefix(WORK_ID_PREFIX).is_some_and(|hex| {
146 !hex.is_empty()
147 && hex
148 .bytes()
149 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
150 })
151}
152
153pub(crate) fn unknown_work_id(work_id: &str) -> String {
155 format!("no background command with work id `{work_id}` in this session")
156}
157
158pub(crate) fn not_available() -> String {
160 format!("'{BACKGROUND_COMMAND_TOOL}': background commands are not available in this session")
161}
162
163pub fn effective_wait(wait_secs: Option<u64>) -> Duration {
166 Duration::from_secs(wait_secs.unwrap_or(MAX_WAIT_SECS).min(MAX_WAIT_SECS))
167}
168
169pub(crate) fn render(
180 snapshot: &CommandSnapshot,
181 waited: Option<Duration>,
182) -> (ToolResult, Option<String>) {
183 let mut lines = vec![
184 format!("work_id: {}", snapshot.work_id),
185 format!("state: {}", snapshot.state.as_str()),
186 format!("elapsed_secs: {}", snapshot.elapsed_ms / 1000),
187 ];
188 if let Some(waited) = waited {
189 lines.push(format!("waited_secs: {}", waited.as_secs()));
190 }
191 let mut metadata = vec![
192 ("work_id".to_string(), snapshot.work_id.clone()),
193 ("state".to_string(), snapshot.state.as_str().to_string()),
194 ];
195 let mut notes = Vec::new();
196 if let Some(completion) = &snapshot.finished {
197 lines.push(format!("exit_code: {}", completion.exit_code));
198 lines.push(format!("status: {}", completion.status()));
199 if let Some(error) = &completion.error {
200 lines.push(format!("wait_error: {error}"));
201 }
202 metadata.push(("exit_code".to_string(), completion.exit_code.to_string()));
203 notes.extend(
204 completion
205 .resource_limit
206 .as_deref()
207 .map(crate::resources::resource_limit_line),
208 );
209 notes.extend(completion.output_holders.note());
210 }
211 let truncated = snapshot.output_bytes > snapshot.tail.len() as u64;
212 lines.push(format!("output: {}", snapshot.output_path));
213 lines.push(format!("output_bytes: {}", snapshot.output_bytes));
214 lines.push(if truncated {
215 format!("output_shown: last {MAX_SHELL_OUTPUT_BYTES} bytes")
216 } else {
217 "output_shown: all".to_string()
218 });
219
220 let shown = if truncated {
221 from_char_boundary(&snapshot.tail)
222 } else {
223 &snapshot.tail
224 };
225 let mut data = lines.join("\n");
226 if !shown.is_empty() {
227 data.push_str("\n\n");
228 data.push_str(&String::from_utf8_lossy(shown));
229 }
230 let summary = match &snapshot.finished {
231 Some(completion) if snapshot.state != CommandState::Running => format!(
232 "{} {}, exit code {}",
233 snapshot.work_id,
234 snapshot.state.as_str(),
235 completion.exit_code
236 ),
237 _ => format!("{} {}", snapshot.work_id, snapshot.state.as_str()),
238 };
239 let result = ToolResult {
240 status: Status::Passed,
241 summary: Some(summary),
242 data: Some(data),
243 data_path: None,
244 truncated,
245 metadata,
246 attachments: Vec::new(),
247 };
248 (result, (!notes.is_empty()).then(|| notes.join("\n")))
249}
250
251fn from_char_boundary(tail: &[u8]) -> &[u8] {
254 let skip = tail
255 .iter()
256 .take(3)
257 .take_while(|byte| (**byte & 0b1100_0000) == 0b1000_0000)
258 .count();
259 &tail[skip..]
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 use serde_json::json;
267
268 #[test]
271 fn the_description_states_operations_and_limits_only() {
272 let description = description();
273 for fact in [
274 "`status`",
275 "`wait`",
276 "`stop`",
277 "wrk_",
278 "600",
279 "16384",
280 "SIGTERM",
281 "SIGKILL",
282 "2 seconds",
283 "process group",
284 "running, exited or stopped",
285 "killed",
286 ] {
287 assert!(description.contains(fact), "{fact} missing: {description}");
288 }
289 let lower = description.to_lowercase();
290 for advice in ["when", "should", "call this", "use this"] {
291 assert!(!lower.contains(advice), "{advice}: {description}");
292 }
293 }
294
295 #[test]
297 fn the_input_schema_names_the_three_operations() {
298 let schema: serde_json::Value = serde_json::from_str(INPUT_SCHEMA).unwrap();
299 assert_eq!(
300 schema["properties"]["operation"]["enum"],
301 json!(["status", "wait", "stop"])
302 );
303 assert_eq!(schema["required"], json!(["operation", "work_id"]));
304 assert_eq!(schema["properties"]["wait_secs"]["minimum"], json!(0));
305 }
306
307 #[test]
308 fn wait_secs_is_capped_at_the_runtime_limit() {
309 assert_eq!(effective_wait(None), Duration::from_secs(600));
310 assert_eq!(effective_wait(Some(0)), Duration::ZERO);
311 assert_eq!(effective_wait(Some(30)), Duration::from_secs(30));
312 assert_eq!(effective_wait(Some(100_000)), Duration::from_secs(600));
313 }
314
315 fn parse(args: serde_json::Value) -> Result<Request, String> {
316 parse_request(args.as_object().expect("a JSON object"))
317 }
318
319 #[test]
320 fn a_request_is_parsed_and_each_refusal_names_its_value() {
321 assert_eq!(
322 parse(json!({"operation": "wait", "work_id": "wrk_0a1b", "wait_secs": 5})),
323 Ok(Request {
324 operation: Operation::Wait,
325 work_id: "wrk_0a1b".to_string(),
326 wait_secs: Some(5),
327 })
328 );
329 let refused = |args: serde_json::Value| parse(args).unwrap_err();
330
331 let malformed = refused(json!({"operation": "status", "work_id": "abc"}));
332 assert!(
333 malformed.contains("`abc`") && malformed.contains("wrk_<hex>"),
334 "{malformed}"
335 );
336 for not_hex in ["wrk_", "wrk_0A1B", "wrk_xyz", "WRK_0a1b"] {
337 let refusal = refused(json!({"operation": "status", "work_id": not_hex}));
338 assert!(refusal.contains(not_hex), "{refusal}");
339 }
340 let missing = refused(json!({"operation": "status"}));
341 assert!(missing.contains("`work_id`"), "{missing}");
342 let restart = refused(json!({"operation": "restart", "work_id": "wrk_0a1b"}));
343 assert!(
344 restart.contains("\"restart\"") && restart.contains("`status`, `wait`, `stop`"),
345 "{restart}"
346 );
347 let negative =
348 refused(json!({"operation": "wait", "work_id": "wrk_0a1b", "wait_secs": -1}));
349 assert!(negative.contains("-1"), "{negative}");
350 }
351
352 fn snapshot(tail: &[u8], output_bytes: u64) -> CommandSnapshot {
353 CommandSnapshot {
354 work_id: "wrk_0a1b".to_string(),
355 state: CommandState::Running,
356 elapsed_ms: 12_345,
357 finished: None,
358 output_path: "/records/logs/wrk_0a1b.log".to_string(),
359 output_bytes,
360 tail: tail.to_vec(),
361 delivered_now: false,
362 }
363 }
364
365 #[test]
368 fn a_running_command_renders_without_exit_lines() {
369 let (result, note) = render(&snapshot(b"tick 1\n", 7), None);
370 assert_eq!(
371 result.data.as_deref(),
372 Some(
373 "work_id: wrk_0a1b\nstate: running\n\
374 elapsed_secs: 12\noutput: /records/logs/wrk_0a1b.log\noutput_bytes: 7\n\
375 output_shown: all\n\ntick 1\n"
376 )
377 );
378 assert!(note.is_none());
379 assert!(!result.truncated);
380 assert!(matches!(result.status, Status::Passed));
381 }
382
383 fn finished(state: CommandState, exit_code: i32) -> CommandSnapshot {
384 CommandSnapshot {
385 state,
386 finished: Some(crate::detached::DetachedCompletion {
387 work_id: "wrk_0a1b".to_string(),
388 binary: "/usr/bin/bash".to_string(),
389 command: "make test".to_string(),
390 exit_code,
391 duration_ms: 12_345,
392 exited_at_ms: 1_000,
393 output_path: "/records/logs/wrk_0a1b.log".to_string(),
394 output_bytes: 7,
395 resource_limit: None,
396 output_holders: crate::shell::OutputHolders::Closed,
397 context_id: "ctx_1".to_string(),
398 provenance: crate::origin::TaskProvenance::derive(
399 crate::origin::TaskOrigin::Completion,
400 None,
401 ),
402 error: None,
403 }),
404 ..snapshot(b"tick 1\n", 7)
405 }
406 }
407
408 #[test]
411 fn a_finished_command_renders_its_exit_lines_and_no_command() {
412 let (exited, _) = render(&finished(CommandState::Exited, 0), None);
413 assert_eq!(
414 exited.data.as_deref(),
415 Some(
416 "work_id: wrk_0a1b\nstate: exited\nelapsed_secs: 12\nexit_code: 0\nstatus: ok\n\
417 output: /records/logs/wrk_0a1b.log\noutput_bytes: 7\noutput_shown: all\n\n\
418 tick 1\n"
419 )
420 );
421 let (stopped, _) = render(
422 &finished(CommandState::Stopped, 143),
423 Some(Duration::from_secs(2)),
424 );
425 let stopped = stopped.data.unwrap();
426 assert!(
427 stopped.lines().any(|line| line == "state: stopped"),
428 "{stopped}"
429 );
430 assert!(
431 stopped.lines().any(|line| line == "exit_code: 143"),
432 "{stopped}"
433 );
434 for data in [exited.data.as_deref().unwrap(), stopped.as_str()] {
435 assert!(
436 !data.lines().any(|line| line.starts_with("command:")),
437 "{data}"
438 );
439 assert!(!data.contains("make test"), "{data}");
440 assert!(!data.contains("/usr/bin/bash"), "{data}");
441 }
442 }
443
444 #[test]
446 fn a_cut_tail_starts_on_a_character() {
447 let mut tail = "é".as_bytes()[1..].to_vec();
448 tail.extend_from_slice(b"rest");
449 let (result, _) = render(
450 &snapshot(&tail, 100_000),
451 Some(Duration::from_millis(1_900)),
452 );
453 let data = result.data.unwrap();
454 assert!(data.contains("waited_secs: 1\n"), "{data}");
455 assert!(data.contains("output_shown: last 16384 bytes"), "{data}");
456 assert!(data.ends_with("\n\nrest"), "{data}");
457 assert!(result.truncated);
458 }
459}