Skip to main content

kanade_shared/wire/
result.rs

1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4/// Prefix injected into the UUIDv5 name string for deriving legacy
5/// `result_id`s. Fixed marker so two backends (or one backend across
6/// restarts) projecting the same legacy payload arrive at the same
7/// id. Tied to the standard `Uuid::NAMESPACE_OID` namespace below.
8/// Bumping this prefix would break dedupe of legacy redeliveries
9/// crossing the upgrade — don't.
10const LEGACY_RESULT_ID_PREFIX: &str = "kanade-issue-19/legacy-result-id:";
11
12#[derive(Serialize, Deserialize, Debug, Clone)]
13pub struct ExecResult {
14    /// v0.29 / Issue #19: agent-minted UUID, unique per (Command, PC)
15    /// run. Replaces `request_id` as the projector's primary key so
16    /// broadcast Commands (commands.all / commands.group.X) — where N
17    /// PCs share one `request_id` — finally persist all N results
18    /// instead of silently dropping all but the first. Pre-v0.29
19    /// agents omit this field; it deserialises as the empty string,
20    /// and [`Self::stable_result_id`] derives a deterministic UUIDv5
21    /// from `(request_id, pc_id)` so legacy payloads (a) get distinct
22    /// ids across broadcast PCs (PC #2's row stops being dropped) and
23    /// (b) get the SAME id on JetStream redelivery (the new `ON
24    /// CONFLICT(result_id) DO NOTHING` path correctly dedupes, so
25    /// `executions.success_count` doesn't double-count across retries).
26    #[serde(default)]
27    pub result_id: String,
28    /// The NATS reply token. Still surfaced for joining back to the
29    /// `kanade run` request/reply path. No longer unique across rows
30    /// (broadcast Commands share it).
31    pub request_id: String,
32    /// v0.29 / Issue #19: back-link to `executions.exec_id`. Copied
33    /// from `Command.exec_id` by the agent. `None` for ad-hoc
34    /// `kanade run` (no deployment) and for results emitted by
35    /// pre-v0.29 agents (decoded via `serde(default)`).
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub exec_id: Option<String>,
38    /// #955: back-link to the parent run's `result_id` for a
39    /// `finalize:` hook's own result row. Set by the agent to the
40    /// triggering run's `result_id` so the SPA can link the
41    /// `<job>__finalize` row to (and from) the run whose collect it
42    /// cleaned up. `None` for every ordinary run and every pre-#955
43    /// payload (`serde(default)` keeps older results decodable).
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub parent_result_id: Option<String>,
46    pub pc_id: String,
47    pub exit_code: i32,
48    /// stdout. Empty string when [`Self::stdout_object`] is set — the
49    /// agent overflowed the bytes into [`crate::kv::OBJECT_RESULT_OUTPUT`]
50    /// because the inline payload would have exceeded NATS's default
51    /// `max_payload` (#227). The backend projector derefs the pointer
52    /// before inserting; SQLite still stores the full text inline so
53    /// the SPA Activity page reads unchanged.
54    pub stdout: String,
55    pub stderr: String,
56    pub started_at: chrono::DateTime<chrono::Utc>,
57    pub finished_at: chrono::DateTime<chrono::Utc>,
58    /// Object Store key under [`crate::kv::OBJECT_RESULT_OUTPUT`] when
59    /// `stdout` overflowed the agent's inline threshold (#227). Set to
60    /// `Some("<request_id>/stdout")` by the agent's outbox drain; the
61    /// backend projector fetches the bytes from that key and uses them
62    /// in place of the (empty) `stdout` field. `None` for the common
63    /// small-stdout case + every pre-#227 payload (`serde(default)`
64    /// keeps older results decodable).
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub stdout_object: Option<String>,
67    /// Sibling of `stdout_object` for the stderr stream. Same key
68    /// shape (`<request_id>/stderr`).
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub stderr_object: Option<String>,
71    /// v0.13: the manifest id that produced this result. Sourced
72    /// from `Command.id` (which is the YAML `manifest.id`, e.g.
73    /// `"inventory-hw"`). Distinct from the per-deploy UUID stored
74    /// in `Command.exec_id`. The results projector uses this to
75    /// look up the manifest's `inventory:` hint and upsert
76    /// `inventory_facts` rows for inventory-tagged jobs.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub manifest_id: Option<String>,
79    /// #219: Object Store key under [`crate::kv::OBJECT_COLLECTIONS`] for
80    /// the bundle this run collected, when the job carried a `collect:`
81    /// hint and the run succeeded. Set by the agent to
82    /// `Some("<pc_id>/<job_id>/<rfc3339>.zip")` after it zips the
83    /// script's listed files and uploads the archive. `None` for every
84    /// non-collect job + every pre-#219 payload (`serde(default)` keeps
85    /// older results decodable). The SPA Collect page lists / downloads
86    /// these straight from the bucket.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub collect_object: Option<String>,
89}
90
91/// Synthetic exit code for a run the agent skipped because the
92/// Command's `version` didn't match the `script_current` pin. One of
93/// the four reserved skip codes (124–127): the agent publishes a
94/// normal [`ExecResult`] so the operator can see *why* nothing ran,
95/// but the script itself never executed — consumers that derive state
96/// from a run's output (e.g. the backend's `check_status` projection)
97/// must treat these as "no new evidence", not as a run (#909).
98pub const EXIT_SKIP_VERSION_PIN: i32 = 124;
99/// Synthetic exit code: `deadline_at` passed before the agent could
100/// fire. See [`EXIT_SKIP_VERSION_PIN`] for the shared contract.
101pub const EXIT_SKIP_DEADLINE: i32 = 125;
102/// Synthetic exit code: the script is revoked in
103/// `BUCKET_SCRIPT_STATUS`. See [`EXIT_SKIP_VERSION_PIN`].
104pub const EXIT_SKIP_REVOKED: i32 = 126;
105/// Synthetic exit code: the `staleness.mode: strict` policy suppressed
106/// the fire. See [`EXIT_SKIP_VERSION_PIN`].
107pub const EXIT_SKIP_STALENESS: i32 = 127;
108
109/// True when `exit_code` is one of the reserved synthetic skip codes
110/// (124–127) — the agent published this result *instead of* running
111/// the script, so it carries no evidence about the script's outcome.
112pub fn is_synthetic_skip(exit_code: i32) -> bool {
113    (EXIT_SKIP_VERSION_PIN..=EXIT_SKIP_STALENESS).contains(&exit_code)
114}
115
116impl ExecResult {
117    /// Return the `result_id` if the agent supplied one (v0.29+
118    /// payloads always do), otherwise derive a stable UUIDv5 from
119    /// `(request_id, pc_id)`. The projector calls this before INSERT
120    /// so legacy payloads still get a non-empty PK, AND so that
121    /// JetStream redeliveries of the same legacy payload hash to the
122    /// same id and dedupe via `ON CONFLICT`. Per-PC fan-out stays
123    /// distinct (different `pc_id` → different hash).
124    pub fn stable_result_id(&self) -> String {
125        if !self.result_id.is_empty() {
126            return self.result_id.clone();
127        }
128        let name = format!(
129            "{LEGACY_RESULT_ID_PREFIX}{}:{}",
130            self.request_id, self.pc_id
131        );
132        Uuid::new_v5(&Uuid::NAMESPACE_OID, name.as_bytes()).to_string()
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use chrono::TimeZone;
140
141    #[test]
142    fn synthetic_skip_covers_exactly_the_reserved_codes() {
143        for code in [
144            EXIT_SKIP_VERSION_PIN,
145            EXIT_SKIP_DEADLINE,
146            EXIT_SKIP_REVOKED,
147            EXIT_SKIP_STALENESS,
148        ] {
149            assert!(is_synthetic_skip(code), "{code} is a reserved skip code");
150        }
151        for code in [0, 1, -1, 123, 128, 255] {
152            assert!(!is_synthetic_skip(code), "{code} is a real exit code");
153        }
154    }
155
156    #[test]
157    fn exec_result_round_trips_through_json() {
158        let t0 = chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap();
159        let t1 = chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 5).unwrap();
160        let r = ExecResult {
161            result_id: "result-uuid-1".into(),
162            request_id: "req-1".into(),
163            exec_id: Some("exec-uuid-1".into()),
164            parent_result_id: None,
165            pc_id: "pc-01".into(),
166            exit_code: 0,
167            stdout: "hello\n".into(),
168            stderr: String::new(),
169            started_at: t0,
170            finished_at: t1,
171            stdout_object: None,
172            stderr_object: None,
173            manifest_id: Some("inventory-hw".into()),
174            collect_object: None,
175        };
176        let json = serde_json::to_string(&r).unwrap();
177        let back: ExecResult = serde_json::from_str(&json).unwrap();
178        assert_eq!(back.result_id, r.result_id);
179        assert_eq!(back.request_id, r.request_id);
180        assert_eq!(back.exec_id.as_deref(), Some("exec-uuid-1"));
181        assert_eq!(back.exit_code, r.exit_code);
182        assert_eq!(back.stdout, r.stdout);
183        assert_eq!(back.started_at, t0);
184        assert_eq!(back.finished_at, t1);
185        assert_eq!(back.manifest_id.as_deref(), Some("inventory-hw"));
186    }
187
188    #[test]
189    fn exec_result_without_manifest_id_decodes() {
190        // Older agents (pre-0.13) sent ExecResult with no manifest_id field.
191        let json = r#"{
192            "request_id":"r","pc_id":"x","exit_code":0,
193            "stdout":"","stderr":"",
194            "started_at":"2026-05-16T00:00:00Z",
195            "finished_at":"2026-05-16T00:00:00Z"
196        }"#;
197        let r: ExecResult = serde_json::from_str(json).unwrap();
198        assert_eq!(r.manifest_id, None);
199    }
200
201    #[test]
202    fn exec_result_without_result_id_decodes_empty() {
203        // v0.29 / Issue #19: pre-v0.29 agents don't send `result_id`.
204        // `#[serde(default)]` decodes it as the empty string so the
205        // projector can detect "legacy payload" and call
206        // `stable_result_id()` to derive a deterministic PK.
207        let json = r#"{
208            "request_id":"r","pc_id":"x","exit_code":0,
209            "stdout":"","stderr":"",
210            "started_at":"2026-05-16T00:00:00Z",
211            "finished_at":"2026-05-16T00:00:00Z"
212        }"#;
213        let r: ExecResult = serde_json::from_str(json).unwrap();
214        assert_eq!(r.result_id, "");
215        assert!(r.exec_id.is_none());
216    }
217
218    #[test]
219    fn stable_result_id_is_deterministic_for_legacy_payload() {
220        // Gemini #65 medium fix: legacy redeliveries (same request_id +
221        // pc_id) must hash to the SAME result_id so the projector's
222        // ON CONFLICT(result_id) DO NOTHING dedupes — otherwise
223        // `executions.success_count` double-counts on JetStream ack
224        // timeouts.
225        let json = r#"{
226            "request_id":"r","pc_id":"x","exit_code":0,
227            "stdout":"","stderr":"",
228            "started_at":"2026-05-16T00:00:00Z",
229            "finished_at":"2026-05-16T00:00:00Z"
230        }"#;
231        let a: ExecResult = serde_json::from_str(json).unwrap();
232        let b: ExecResult = serde_json::from_str(json).unwrap();
233        assert_eq!(
234            a.stable_result_id(),
235            b.stable_result_id(),
236            "same legacy payload must hash to the same result_id",
237        );
238    }
239
240    #[test]
241    fn stable_result_id_differs_across_pcs_for_broadcast() {
242        // The other half: a broadcast Command published to two PCs
243        // produces two legacy ExecResults sharing one request_id but
244        // with different pc_ids. Each must get its OWN result_id so
245        // both rows persist (the whole point of Issue #19).
246        let json_a = r#"{
247            "request_id":"shared","pc_id":"pc-1","exit_code":0,
248            "stdout":"","stderr":"",
249            "started_at":"2026-05-16T00:00:00Z",
250            "finished_at":"2026-05-16T00:00:00Z"
251        }"#;
252        let json_b = r#"{
253            "request_id":"shared","pc_id":"pc-2","exit_code":0,
254            "stdout":"","stderr":"",
255            "started_at":"2026-05-16T00:00:00Z",
256            "finished_at":"2026-05-16T00:00:00Z"
257        }"#;
258        let a: ExecResult = serde_json::from_str(json_a).unwrap();
259        let b: ExecResult = serde_json::from_str(json_b).unwrap();
260        assert_ne!(
261            a.stable_result_id(),
262            b.stable_result_id(),
263            "different pc_id must produce a different result_id",
264        );
265    }
266
267    #[test]
268    fn stable_result_id_passes_through_explicit_value() {
269        // v0.29 agents always supply result_id; the helper must
270        // return that as-is (no surprise re-hashing).
271        let r = ExecResult {
272            result_id: "agent-minted-uuid".into(),
273            request_id: "r".into(),
274            exec_id: None,
275            parent_result_id: None,
276            pc_id: "x".into(),
277            exit_code: 0,
278            stdout: String::new(),
279            stderr: String::new(),
280            started_at: chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap(),
281            finished_at: chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap(),
282            stdout_object: None,
283            stderr_object: None,
284            manifest_id: None,
285            collect_object: None,
286        };
287        assert_eq!(r.stable_result_id(), "agent-minted-uuid");
288    }
289
290    #[test]
291    fn exec_result_collect_object_round_trips_and_omits_when_absent() {
292        // #219: collect_object is off the wire when None
293        // (skip_serializing_if) so pre-#219 readers stay compatible...
294        let t0 = chrono::Utc.with_ymd_and_hms(2026, 6, 15, 0, 0, 0).unwrap();
295        let mut r = ExecResult {
296            result_id: "r1".into(),
297            request_id: "req".into(),
298            exec_id: None,
299            parent_result_id: None,
300            pc_id: "PC1".into(),
301            exit_code: 0,
302            stdout: String::new(),
303            stderr: String::new(),
304            started_at: t0,
305            finished_at: t0,
306            stdout_object: None,
307            stderr_object: None,
308            manifest_id: Some("collect-diagnostics".into()),
309            collect_object: None,
310        };
311        let json = serde_json::to_string(&r).unwrap();
312        assert!(
313            !json.contains("collect_object"),
314            "collect_object must be absent when None: {json}"
315        );
316        // ...and a set key survives the round-trip.
317        r.collect_object = Some("PC1/collect-diagnostics/20260615T000000Z.zip".into());
318        let back: ExecResult = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
319        assert_eq!(
320            back.collect_object.as_deref(),
321            Some("PC1/collect-diagnostics/20260615T000000Z.zip"),
322        );
323    }
324
325    #[test]
326    fn exec_result_parent_result_id_round_trips_and_omits_when_absent() {
327        // #955: a finalize row carries `parent_result_id`; ordinary runs
328        // leave it None, and it stays off the wire so pre-#955 readers
329        // are unaffected (skip_serializing_if).
330        let t0 = chrono::Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap();
331        let mut r = ExecResult {
332            result_id: "fin-1".into(),
333            request_id: "req__finalize".into(),
334            exec_id: None,
335            parent_result_id: None,
336            pc_id: "PC1".into(),
337            exit_code: 0,
338            stdout: String::new(),
339            stderr: String::new(),
340            started_at: t0,
341            finished_at: t0,
342            stdout_object: None,
343            stderr_object: None,
344            manifest_id: Some("screenshot-collect__finalize".into()),
345            collect_object: None,
346        };
347        let json = serde_json::to_string(&r).unwrap();
348        assert!(
349            !json.contains("parent_result_id"),
350            "parent_result_id must be absent when None: {json}"
351        );
352        r.parent_result_id = Some("parent-run-uuid".into());
353        let back: ExecResult = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
354        assert_eq!(back.parent_result_id.as_deref(), Some("parent-run-uuid"));
355    }
356}