Skip to main content

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}