1use std::collections::VecDeque;
19use std::ffi::OsString;
20use std::fs::File;
21use std::io::{BufRead, BufReader, Seek, SeekFrom};
22use std::path::{Path, PathBuf};
23
24pub const TAIL_WINDOW_BYTES: u64 = 64 * 1024;
30
31pub(crate) fn task_output_base() -> PathBuf {
36 base_of(std::env::var_os("CLAUDE_CODE_TMPDIR"))
37}
38
39fn base_of(tmpdir: Option<OsString>) -> PathBuf {
43 let raw = tmpdir.map_or_else(|| PathBuf::from("/tmp"), PathBuf::from);
44 std::fs::canonicalize(&raw).unwrap_or(raw)
47}
48
49pub(crate) fn compose_task_output_path(
58 base: &Path,
59 session_id: &str,
60 task_id: &str,
61) -> Option<PathBuf> {
62 let mine = base.join(format!("claude-{}", rustix::process::getuid().as_raw()));
63 let mut found: Option<PathBuf> = None;
64 for entry in std::fs::read_dir(&mine).ok()?.flatten() {
65 let candidate =
66 entry.path().join(session_id).join("tasks").join(format!("{task_id}.output"));
67 if candidate.is_file() {
68 if found.is_some() {
69 return None;
70 }
71 found = Some(candidate);
72 }
73 }
74 found
75}
76
77pub(crate) fn probe_task_output_layout(base: &Path) {
91 LAYOUT_PROBED.call_once(|| {
92 let mine = base.join(format!("claude-{}", rustix::process::getuid().as_raw()));
93 if !mine.is_dir() {
94 tracing::warn!(
95 event_name = "task_output_layout_missing",
96 outcome = "no_layout",
97 base = %base.display(),
98 "the CLI's background-task directory is not where this build expects it; a \
99 running call's output tail will answer no-path until the layout matches",
100 );
101 }
102 });
103}
104
105static LAYOUT_PROBED: std::sync::Once = std::sync::Once::new();
107
108pub fn read_output_file_tail(path: &Path, max_lines: usize) -> Option<Vec<String>> {
127 if max_lines == 0 {
128 return Some(Vec::new());
129 }
130 let mut file = match File::open(path) {
131 Ok(f) => f,
132 Err(err) => {
133 tracing::debug!(
138 event_name = "output_tail_open_failed",
139 message = "could not open an output file; tail unavailable",
140 outcome = "failure",
141 path = %path.display(),
142 error_kind = ?err.kind(),
143 error_message = %err,
144 );
145 return None;
146 }
147 };
148 let file_len = file.metadata().map_or(0, |m| m.len());
152 let drop_partial_first = file_len > TAIL_WINDOW_BYTES
153 && file.seek(SeekFrom::Start(file_len - TAIL_WINDOW_BYTES)).is_ok();
154 let mut ring: VecDeque<String> = VecDeque::with_capacity(max_lines);
155 for (idx, line) in BufReader::new(file).lines().enumerate() {
156 match line {
157 Ok(text) => {
158 if idx == 0 && drop_partial_first {
159 continue;
160 }
161 if ring.len() == max_lines {
162 ring.pop_front();
163 }
164 ring.push_back(text);
165 }
166 Err(err) => {
167 tracing::debug!(
177 event_name = "output_tail_line_unreadable",
178 message = "an output file holds a line that could not be read; skipping it",
179 outcome = "skipped",
180 path = %path.display(),
181 error_kind = ?err.kind(),
182 );
183 }
184 }
185 }
186 Some(ring.into_iter().collect())
187}
188
189pub const CALL_OUTPUT_MAX_LINES: usize = 12;
191
192pub fn resolve_call_output_path(
200 history: &[forge_primitives::Message],
201 call_id: &str,
202) -> Option<PathBuf> {
203 resolve_call_output_path_at(history, call_id, task_output_base)
204}
205
206pub(crate) fn resolve_call_output_path_at(
209 history: &[forge_primitives::Message],
210 call_id: &str,
211 base: impl FnOnce() -> PathBuf,
212) -> Option<PathBuf> {
213 let named = history.iter().find_map(|message| match message {
214 forge_primitives::Message::TaskNotification {
215 tool_use_id: Some(id), output_file, ..
216 } if id == call_id && !output_file.is_empty() => Some(PathBuf::from(output_file)),
217 _ => None,
218 });
219 if named.is_some() {
220 return named;
221 }
222 let (session_id, task_id) = history.iter().find_map(|message| match message {
223 forge_primitives::Message::TaskStarted {
224 task_id,
225 tool_use_id: Some(id),
226 session_id,
227 ..
228 } if id == call_id => Some((session_id.clone(), task_id.clone())),
229 _ => None,
230 })?;
231 let base = base();
232 probe_task_output_layout(&base);
233 compose_task_output_path(&base, &session_id, &task_id)
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use std::io::Write;
240
241 fn scratch_base(tag: &str) -> std::path::PathBuf {
244 let base = std::env::temp_dir()
245 .join(format!("forge-task-output-compose-{}-{tag}", std::process::id()));
246 let _ = std::fs::remove_dir_all(&base);
247 std::fs::create_dir_all(&base).expect("create the scratch base");
248 base
249 }
250
251 fn plant_file(base: &Path, slug: &str, session_id: &str, task_id: &str, contents: &str) {
252 let dir = base
253 .join(format!("claude-{}", rustix::process::getuid().as_raw()))
254 .join(slug)
255 .join(session_id)
256 .join("tasks");
257 std::fs::create_dir_all(&dir).expect("create the scratch tasks tree");
258 std::fs::write(dir.join(format!("{task_id}.output")), contents).expect("plant the file");
259 }
260
261 fn task_started(task_id: &str, call: &str, session_id: &str) -> forge_primitives::Message {
263 serde_json::from_value(serde_json::json!({
264 "type": "system",
265 "subtype": "task_started",
266 "task_id": task_id,
267 "description": "watch the run",
268 "uuid": format!("u-{task_id}"),
269 "session_id": session_id,
270 "tool_use_id": call,
271 }))
272 .expect("a task_started frame")
273 }
274
275 fn task_notification(
277 task_id: &str,
278 call: &str,
279 session_id: &str,
280 output_file: &str,
281 ) -> forge_primitives::Message {
282 serde_json::from_value(serde_json::json!({
283 "type": "system",
284 "subtype": "task_notification",
285 "task_id": task_id,
286 "status": "completed",
287 "output_file": output_file,
288 "summary": "Background command \"true\" completed",
289 "uuid": format!("u-{task_id}"),
290 "session_id": session_id,
291 "tool_use_id": call,
292 }))
293 .expect("a task_notification frame")
294 }
295
296 #[test]
301 fn the_ending_frames_path_wins_over_the_composed_one() {
302 let base = scratch_base("resolve-wins");
303 plant_file(&base, "slug-any", "sess-live", "t-live", "composed\n");
304 let named = base.join("named-by-the-ending-frame.output");
305 std::fs::write(&named, "named\n").expect("write the named file");
306
307 let started = task_started("t-live", "tu-live", "sess-live");
308 let ended =
309 task_notification("t-live", "tu-live", "sess-live", &named.display().to_string());
310
311 assert_eq!(
312 resolve_call_output_path_at(std::slice::from_ref(&started), "tu-live", || base.clone()),
313 compose_task_output_path(&base, "sess-live", "t-live"),
314 "while it runs, the composed path is the one the read uses",
315 );
316 assert_eq!(
317 resolve_call_output_path_at(&[started, ended], "tu-live", || base.clone()),
318 Some(named),
319 "and once the ending frame lands, its own path overrides the composition",
320 );
321 let _ = std::fs::remove_dir_all(&base);
322 }
323
324 #[test]
331 fn nothing_reads_the_launch_sentence_for_the_path() {
332 let base = scratch_base("resolve-prose");
333 plant_file(&base, "slug-any", "sess-prose", "t-prose", "from the composition\n");
334 let composed = compose_task_output_path(&base, "sess-prose", "t-prose")
335 .expect("the composition finds its file");
336 let named_by_prose = base.join("named-by-the-launch-sentence.output");
338 std::fs::write(&named_by_prose, "from the launch sentence\n").expect("write file A");
339
340 let launch: forge_primitives::Message = serde_json::from_value(serde_json::json!({
341 "type": "user",
342 "message": {
343 "role": "user",
344 "content": [{
345 "type": "tool_result",
346 "tool_use_id": "tu-prose",
347 "is_error": false,
348 "content": format!(
349 "Command running in background with ID: t-prose. Output is being \
350 written to: {}. You will be notified when it completes. To check \
351 interim output, use Read on that file path.",
352 named_by_prose.display(),
353 ),
354 }],
355 },
356 "tool_use_result": { "backgroundTaskId": "t-prose" },
357 "uuid": "u-prose",
358 "session_id": "sess-prose",
359 }))
360 .expect("a launch result frame");
361 let started = task_started("t-prose", "tu-prose", "sess-prose");
362
363 assert_eq!(
364 resolve_call_output_path_at(&[launch.clone(), started.clone()], "tu-prose", || base
365 .clone()),
366 Some(composed.clone()),
367 "the composed path answers, not the one the launch sentence names",
368 );
369 std::fs::remove_file(&composed).expect("unplant the composed file");
370 assert_eq!(
371 resolve_call_output_path_at(&[launch, started], "tu-prose", || base.clone()),
372 None,
373 "and with the composed file gone the answer is no-path, never the sentence's file",
374 );
375 let _ = std::fs::remove_dir_all(&base);
376 }
377
378 #[test]
383 fn two_running_calls_each_read_their_own_task() {
384 let base = scratch_base("resolve-two");
385 plant_file(&base, "slug-any", "sess-two", "t-a", "task a\n");
386 plant_file(&base, "slug-any", "sess-two", "t-b", "task b\n");
387 let history =
388 vec![task_started("t-a", "tu-a", "sess-two"), task_started("t-b", "tu-b", "sess-two")];
389
390 assert_eq!(
391 resolve_call_output_path_at(&history, "tu-a", || base.clone()),
392 compose_task_output_path(&base, "sess-two", "t-a"),
393 "the first call reads its own task's file",
394 );
395 assert_eq!(
396 resolve_call_output_path_at(&history, "tu-b", || base.clone()),
397 compose_task_output_path(&base, "sess-two", "t-b"),
398 "and the second reads its own, not the first's",
399 );
400 let _ = std::fs::remove_dir_all(&base);
401 }
402
403 #[test]
407 fn a_call_with_no_frame_and_one_with_an_empty_path_are_both_no_path() {
408 assert_eq!(
409 resolve_call_output_path(&[], "tu-unknown"),
410 None,
411 "a call no frame names is no path at all",
412 );
413 let ended = task_notification("t-empty", "tu-empty", "sess-empty", "");
414 assert_eq!(
415 resolve_call_output_path(&[ended], "tu-empty"),
416 None,
417 "and an empty output_file is a path the frame does not carry, not one that is gone",
418 );
419 }
420
421 fn write_tmp(contents: &str) -> std::path::PathBuf {
422 use std::hash::{Hash, Hasher};
424 let dir = std::env::temp_dir();
425 let nonce = std::process::id();
426 let mut hasher = std::collections::hash_map::DefaultHasher::new();
427 contents.hash(&mut hasher);
428 let id = hasher.finish();
429 let path = dir.join(format!("forge_monitor_tail_test_{nonce}_{id}.log"));
430 let mut f = File::create(&path).expect("create tmp");
431 f.write_all(contents.as_bytes()).expect("write tmp");
432 path
433 }
434
435 #[test]
439 fn compose_finds_the_task_under_any_slug_the_cli_made() {
440 let base = scratch_base("slug");
441 plant_file(&base, "some-moved-slug", "sess-1", "t-1", "hello\n");
442 assert_eq!(
443 compose_task_output_path(&base, "sess-1", "t-1"),
444 Some(
445 base.join(format!("claude-{}", rustix::process::getuid().as_raw()))
446 .join("some-moved-slug")
447 .join("sess-1")
448 .join("tasks")
449 .join("t-1.output"),
450 ),
451 "the file is found with the slug component wildcarded",
452 );
453 let _ = std::fs::remove_dir_all(&base);
454 }
455
456 #[test]
459 fn compose_ignores_a_tree_under_another_uid() {
460 let base = scratch_base("uid");
461 let other = base
462 .join(format!("claude-{}", rustix::process::getuid().as_raw() + 1))
463 .join("slug")
464 .join("sess-1")
465 .join("tasks");
466 std::fs::create_dir_all(&other).expect("create the other user's tree");
467 std::fs::write(other.join("t-1.output"), "not ours\n").expect("plant it");
468 assert_eq!(
469 compose_task_output_path(&base, "sess-1", "t-1"),
470 None,
471 "a tree under another uid is not this process's to read",
472 );
473 let _ = std::fs::remove_dir_all(&base);
474 }
475
476 #[test]
479 fn compose_answers_none_while_no_file_is_there_yet() {
480 let base = scratch_base("absent");
481 assert_eq!(compose_task_output_path(&base, "sess-1", "t-1"), None);
482 let _ = std::fs::remove_dir_all(&base);
483 }
484
485 #[test]
488 fn compose_answers_none_when_two_slugs_hold_the_file() {
489 let base = scratch_base("two");
490 plant_file(&base, "slug-a", "sess-1", "t-1", "first\n");
491 plant_file(&base, "slug-b", "sess-1", "t-1", "second\n");
492 assert_eq!(
493 compose_task_output_path(&base, "sess-1", "t-1"),
494 None,
495 "two candidates is a miss the caller draws as no-path",
496 );
497 let _ = std::fs::remove_dir_all(&base);
498 }
499
500 #[test]
502 fn the_base_is_the_tmpdir_the_child_runs_with() {
503 assert_eq!(
504 base_of(None),
505 std::fs::canonicalize("/tmp").expect("the real temp dir"),
506 "no CLAUDE_CODE_TMPDIR in the child's env means /tmp, realpath'd",
507 );
508 let scratch = scratch_base("base");
509 assert_eq!(
510 base_of(Some(scratch.clone().into_os_string())),
511 std::fs::canonicalize(&scratch).expect("the scratch base canonicalizes"),
512 "a set CLAUDE_CODE_TMPDIR is the base, and it is realpath'd the way the CLI does",
513 );
514 let _ = std::fs::remove_dir_all(&scratch);
515 }
516
517 #[test]
518 fn tail_returns_last_n_lines_in_order() {
519 let path = write_tmp(
520 "alpha\nbravo\ncharlie\ndelta\necho\nfoxtrot\ngolf\nhotel\nindia\njuliet\nkilo\nlima\nmike\nnovember\noscar\n",
521 );
522 let lines = read_output_file_tail(&path, 12).expect("read ok");
523 assert_eq!(lines.len(), 12);
524 assert_eq!(lines.first().map(String::as_str), Some("delta"));
526 assert_eq!(lines.last().map(String::as_str), Some("oscar"));
527 let _ = std::fs::remove_file(&path);
528 }
529
530 #[test]
531 fn tail_under_cap_returns_all_lines() {
532 let path = write_tmp("one\ntwo\nthree\n");
533 let lines = read_output_file_tail(&path, 12).expect("read ok");
534 assert_eq!(lines, vec!["one".to_owned(), "two".to_owned(), "three".to_owned()]);
535 let _ = std::fs::remove_file(&path);
536 }
537
538 #[test]
539 fn empty_file_returns_empty_vec() {
540 let path = write_tmp("");
541 let lines = read_output_file_tail(&path, 12).expect("read ok");
542 assert!(lines.is_empty());
543 let _ = std::fs::remove_file(&path);
544 }
545
546 #[test]
547 fn missing_file_returns_none() {
548 let path = std::path::PathBuf::from("/nonexistent/forge_monitor_output_file_test.log");
549 let result = read_output_file_tail(&path, 12);
550 assert!(result.is_none(), "missing file must return None so caller skips replace");
551 }
552
553 #[test]
554 fn zero_max_lines_returns_empty_vec_without_reading() {
555 let path = std::path::PathBuf::from("/nonexistent/zero_lines.log");
558 let result = read_output_file_tail(&path, 0).expect("zero max yields Ok(empty)");
559 assert!(result.is_empty());
560 }
561
562 #[test]
563 fn read_output_file_tail_hands_over_the_bytes_the_command_wrote() {
564 let raw = "\
570\x1b[32mline 1 green\x1b[0m\n\
571line 2 with \rcarriage return\n\
572\x1b[1;31mline 3 bold red\x1b[0m\x07\n\
573line 4 with \x08\x08backspace\n";
574 let path = write_tmp(raw);
575 let tail = read_output_file_tail(&path, 12).expect("read ok");
576
577 assert!(
578 tail.iter().any(|line| line.contains("\u{1b}[32m")),
579 "the colour the command wrote is still in the tail: {tail:?}",
580 );
581 assert!(
582 tail.iter().any(|line| line.contains('\r')),
583 "the carriage return the command wrote is still in the tail: {tail:?}",
584 );
585 assert!(
586 tail.iter().any(|line| line.contains("line 1 green")),
587 "and the words it wrote are there beside them: {tail:?}",
588 );
589 let _ = std::fs::remove_file(&path);
590 }
591
592 #[test]
593 fn handles_trailing_partial_line_without_newline() {
594 let path = write_tmp("one\ntwo\npartial-without-newline");
597 let lines = read_output_file_tail(&path, 12).expect("read ok");
598 assert_eq!(
599 lines,
600 vec!["one".to_owned(), "two".to_owned(), "partial-without-newline".to_owned()]
601 );
602 let _ = std::fs::remove_file(&path);
603 }
604
605 #[test]
606 fn large_file_reads_only_the_tail_window() {
607 use std::fmt::Write as _;
611 let filler = "x".repeat(100);
612 let mut contents = String::new();
613 for i in 0..1200 {
614 let _ = writeln!(contents, "filler-{i}-{filler}");
615 }
616 for tag in ["tail-a", "tail-b", "tail-c"] {
617 contents.push_str(tag);
618 contents.push('\n');
619 }
620 assert!(contents.len() as u64 > TAIL_WINDOW_BYTES, "fixture must exceed the window");
621 let path = write_tmp(&contents);
622 let lines = read_output_file_tail(&path, 3).expect("read ok");
623 assert_eq!(lines, vec!["tail-a".to_owned(), "tail-b".to_owned(), "tail-c".to_owned()]);
624 let _ = std::fs::remove_file(&path);
625 }
626}