kanade_shared/wire/jobtail.rs
1//! Wire types for the live job-tail path
2//! (`job.tail.<pc_id>` NATS request/reply).
3//!
4//! Operator / backend → agent: [`JobTailRequest`] (the `result_id`
5//! of an in-flight run). Agent → operator: [`JobTailReply`] carrying
6//! the current ring-buffer tail of that job's stdout/stderr.
7//!
8//! Unlike `logs.fetch.<pc_id>` (which tails the agent's whole rolling
9//! log file), this is scoped to a single job's captured output and
10//! only ever returns data while the run is live (plus a short grace
11//! window after it finishes — see `kanade-agent::live_tail`). Once the
12//! agent has dropped the live buffer, `found = false` and the caller
13//! falls back to the persisted `execution_results` row.
14
15use serde::{Deserialize, Serialize};
16
17/// Request the live tail of a single job's output. The `result_id`
18/// is the agent-minted per-PC UUID that keys `execution_results`, so
19/// the SPA already has it from the Activity row / detail page.
20#[derive(Serialize, Deserialize, Debug, Clone, Default)]
21#[serde(default)]
22pub struct JobTailRequest {
23 pub result_id: String,
24}
25
26/// Agent's reply with the current captured tail.
27///
28/// `found = false` means the agent has no live buffer for this
29/// `result_id` (never started here, or already evicted past the grace
30/// window) — the caller should fall back to the stored result row.
31#[derive(Serialize, Deserialize, Debug, Clone, Default)]
32#[serde(default)]
33pub struct JobTailReply {
34 /// The agent currently holds a live buffer for this `result_id`.
35 pub found: bool,
36 /// The child process is still executing. `false` once it has
37 /// exited but the buffer is being retained through the grace
38 /// window so the final tail is still serveable.
39 pub running: bool,
40 /// stdout tail (lossy UTF-8). Bounded by the agent's ring cap.
41 pub stdout: String,
42 /// stderr tail (lossy UTF-8). Bounded by the agent's ring cap.
43 pub stderr: String,
44 /// The ring dropped older stdout bytes to stay under the cap, so
45 /// `stdout` is a suffix of the real output, not the whole of it.
46 pub stdout_truncated: bool,
47 /// As `stdout_truncated`, for stderr.
48 pub stderr_truncated: bool,
49}
50
51#[cfg(test)]
52mod tests {
53 use super::*;
54
55 #[test]
56 fn request_round_trips() {
57 let req = JobTailRequest {
58 result_id: "abc-123".into(),
59 };
60 let json = serde_json::to_string(&req).unwrap();
61 let back: JobTailRequest = serde_json::from_str(&json).unwrap();
62 assert_eq!(back.result_id, "abc-123");
63 }
64
65 #[test]
66 fn request_missing_field_defaults_empty() {
67 let req: JobTailRequest = serde_json::from_str("{}").unwrap();
68 assert_eq!(req.result_id, "");
69 }
70
71 #[test]
72 fn reply_defaults_are_not_found() {
73 let reply = JobTailReply::default();
74 assert!(!reply.found);
75 assert!(!reply.running);
76 assert!(reply.stdout.is_empty());
77 }
78
79 #[test]
80 fn reply_round_trips() {
81 let reply = JobTailReply {
82 found: true,
83 running: true,
84 stdout: "hello".into(),
85 stderr: "".into(),
86 stdout_truncated: true,
87 stderr_truncated: false,
88 };
89 let json = serde_json::to_string(&reply).unwrap();
90 let back: JobTailReply = serde_json::from_str(&json).unwrap();
91 assert!(back.found);
92 assert!(back.running);
93 assert_eq!(back.stdout, "hello");
94 assert!(back.stdout_truncated);
95 }
96}