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    /// Whether the agent ran the script, as the agent itself reports it.
49    ///
50    /// - `Some(true)`: it published this result *instead of* running the
51    ///   script because policy (or this OS) said "not now": deadline /
52    ///   revoke / version-pin / staleness / unsupported-OS. Such a result
53    ///   says nothing about the script's outcome, so aggregations count it
54    ///   as `skipped`, never as a failure.
55    /// - `Some(false)`: authoritative "this is not a skip" — the script ran
56    ///   (whatever it exited, including 126 / 127, which a real script under
57    ///   sh returns for "not executable" / "command not found"), or the
58    ///   agent refused the command ([`EXIT_REJECTED_UNSIGNED`]). A refusal
59    ///   is deliberately not a skip: "this command was not authorised" is
60    ///   something the fleet's failure counts must surface.
61    /// - `None`: the key was absent, i.e. an agent that predates the flag.
62    ///   See [`Self::is_reported_skip`] and
63    ///   [`Self::skips_check_projection`] for how each consumer reads it.
64    ///
65    /// Agents that know the flag always send it (never `null`), so absence
66    /// means exactly "legacy agent". The reserved exit codes only say *why*.
67    #[serde(default)]
68    pub skipped: Option<bool>,
69    /// stdout. Empty string when [`Self::stdout_object`] is set — the
70    /// agent overflowed the bytes into [`crate::kv::OBJECT_RESULT_OUTPUT`]
71    /// because the inline payload would have exceeded NATS's default
72    /// `max_payload` (#227). The backend projector derefs the pointer
73    /// before inserting; SQLite still stores the full text inline so
74    /// the SPA Activity page reads unchanged.
75    pub stdout: String,
76    pub stderr: String,
77    pub started_at: chrono::DateTime<chrono::Utc>,
78    pub finished_at: chrono::DateTime<chrono::Utc>,
79    /// Object Store key under [`crate::kv::OBJECT_RESULT_OUTPUT`] when
80    /// `stdout` overflowed the agent's inline threshold (#227). Set to
81    /// `Some("<request_id>/stdout")` by the agent's outbox drain; the
82    /// backend projector fetches the bytes from that key and uses them
83    /// in place of the (empty) `stdout` field. `None` for the common
84    /// small-stdout case + every pre-#227 payload (`serde(default)`
85    /// keeps older results decodable).
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub stdout_object: Option<String>,
88    /// Sibling of `stdout_object` for the stderr stream. Same key
89    /// shape (`<request_id>/stderr`).
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub stderr_object: Option<String>,
92    /// v0.13: the manifest id that produced this result. Sourced
93    /// from `Command.id` (which is the YAML `manifest.id`, e.g.
94    /// `"inventory-hw"`). Distinct from the per-deploy UUID stored
95    /// in `Command.exec_id`. The results projector uses this to
96    /// look up the manifest's `inventory:` hint and upsert
97    /// `inventory_facts` rows for inventory-tagged jobs.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub manifest_id: Option<String>,
100    /// #219: Object Store key under [`crate::kv::OBJECT_COLLECTIONS`] for
101    /// the bundle this run collected, when the job carried a `collect:`
102    /// hint and the run succeeded. Set by the agent to
103    /// `Some("<pc_id>/<job_id>/<rfc3339>.zip")` after it zips the
104    /// script's listed files and uploads the archive. `None` for every
105    /// non-collect job + every pre-#219 payload (`serde(default)` keeps
106    /// older results decodable). The SPA Collect page lists / downloads
107    /// these straight from the bucket.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub collect_object: Option<String>,
110}
111
112/// Synthetic exit code for a run the agent skipped because the
113/// Command's `version` didn't match the `script_current` pin. One of
114/// the reserved synthetic codes (122–127) saying *why* the agent
115/// published a result instead of running the script; the result
116/// carries [`ExecResult::skipped`], which is what consumers key on —
117/// the code alone proves nothing, as a real script can exit with the
118/// same number. Consumers that derive state from a run's output (e.g.
119/// the backend's `check_status` projection) treat a skip as "no new
120/// evidence", not as a run (#909).
121pub const EXIT_SKIP_VERSION_PIN: i32 = 124;
122/// Synthetic exit code: `deadline_at` passed before the agent could
123/// fire. See [`EXIT_SKIP_VERSION_PIN`] for the shared contract.
124pub const EXIT_SKIP_DEADLINE: i32 = 125;
125/// Synthetic exit code: the script is revoked in
126/// `BUCKET_SCRIPT_STATUS`. See [`EXIT_SKIP_VERSION_PIN`].
127pub const EXIT_SKIP_REVOKED: i32 = 126;
128/// Synthetic exit code: the `staleness.mode: strict` policy suppressed
129/// the fire. See [`EXIT_SKIP_VERSION_PIN`].
130pub const EXIT_SKIP_STALENESS: i32 = 127;
131/// Synthetic exit code: the agent **refused** the command because its
132/// provenance signature did not check out (#1165 stage 3).
133///
134/// Extends the reserved block downwards, because 124–127 was full. The script
135/// never ran, so this is not evidence about its outcome — but it is not a
136/// *skip*: the others mean "policy (or this OS) said not now", while this one
137/// means "this command was not authorised". So the result is published with
138/// [`ExecResult::skipped`] `Some(false)` and counts as a failure everywhere
139/// results are tallied: a fleet refusing unsigned commands must show up in
140/// the failure counts, not vanish into "skipped". The one consumer that only
141/// asks whether the script ran — the `check_status` projection — recognises a
142/// refusal by [`ExecResult::is_signature_refusal`] instead.
143///
144/// Emitting a result at all is the point. `kanade run` waits on
145/// `results.<request_id>`, so a refusal that published nothing would be
146/// indistinguishable from an agent that is simply gone — and the most likely
147/// refusal in practice is an operator's own break-glass command going stale on
148/// a host whose clock is wrong, during an incident, with the backend down and
149/// the obs event stuck in the outbox.
150pub const EXIT_REJECTED_UNSIGNED: i32 = 123;
151/// Synthetic exit code: the schedule needs a feature this agent's OS
152/// **cannot evaluate or source** — a `constraints.require` gate with no
153/// sensing on this platform, or a `when.on` event this OS never emits.
154/// Emitted by the agent's local scheduler, which fails closed (the job is
155/// not run and no per-pc completion is recorded, so it still runs once the
156/// gate becomes supported). Grew the reserved block downwards again (123
157/// was the edge); shares the [`EXIT_SKIP_VERSION_PIN`] contract — the
158/// script never ran.
159pub const EXIT_SKIP_UNSUPPORTED: i32 = 122;
160
161/// The deterministic `result_id` of the signature refusal for
162/// `(request_id, pc_id)` (#1165). Deterministic because a refusal is the one
163/// result that repeats — the replay re-delivers the same unverifiable command
164/// on every reconnect — so every re-publish must land on the same row. It also
165/// makes a refusal recognisable without trusting the exit code alone: see
166/// [`ExecResult::is_signature_refusal`].
167pub fn signature_refusal_result_id(request_id: &str, pc_id: &str) -> String {
168    Uuid::new_v5(
169        &Uuid::NAMESPACE_OID,
170        format!("{request_id}|{pc_id}|signature-refused").as_bytes(),
171    )
172    .to_string()
173}
174
175impl ExecResult {
176    /// Return the `result_id` if the agent supplied one (v0.29+
177    /// payloads always do), otherwise derive a stable UUIDv5 from
178    /// `(request_id, pc_id)`. The projector calls this before INSERT
179    /// so legacy payloads still get a non-empty PK, AND so that
180    /// JetStream redeliveries of the same legacy payload hash to the
181    /// same id and dedupe via `ON CONFLICT`. Per-PC fan-out stays
182    /// distinct (different `pc_id` → different hash).
183    pub fn stable_result_id(&self) -> String {
184        if !self.result_id.is_empty() {
185            return self.result_id.clone();
186        }
187        let name = format!(
188            "{LEGACY_RESULT_ID_PREFIX}{}:{}",
189            self.request_id, self.pc_id
190        );
191        Uuid::new_v5(&Uuid::NAMESPACE_OID, name.as_bytes()).to_string()
192    }
193
194    /// True when the agent reported this result as a skip. What every
195    /// result tally (and `execution_results.skipped`) counts. A legacy
196    /// result (`None`) is not one: tallies read those by exit code, exactly
197    /// as they did before the flag existed.
198    pub fn is_reported_skip(&self) -> bool {
199        self.skipped == Some(true)
200    }
201
202    /// True when this result must be kept out of the `check_status`
203    /// projection because the script never ran (#909). Follows the flag
204    /// when the agent sent one. For a legacy result (`None`) it keeps the
205    /// rule the projector applied before the flag existed: every reserved
206    /// exit code 122..=127 is dropped. A legacy agent cannot tell its own
207    /// skip from a script that really exited 126 / 127, so those real exits
208    /// stay dropped for legacy agents too — unchanged behaviour, not a new
209    /// guess. (The signature refusal is handled separately, see
210    /// [`Self::is_signature_refusal`].)
211    pub fn skips_check_projection(&self) -> bool {
212        match self.skipped {
213            Some(skipped) => skipped,
214            None => (EXIT_SKIP_UNSUPPORTED..=EXIT_SKIP_STALENESS).contains(&self.exit_code),
215        }
216    }
217
218    /// True for the agent's signature refusal (#1165): exit
219    /// [`EXIT_REJECTED_UNSIGNED`] under the refusal's derived `result_id`.
220    /// A script that merely exits 123 (xargs does, for one) carries an
221    /// ordinary random id and is not a refusal.
222    pub fn is_signature_refusal(&self) -> bool {
223        self.exit_code == EXIT_REJECTED_UNSIGNED
224            && self.result_id == signature_refusal_result_id(&self.request_id, &self.pc_id)
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use chrono::TimeZone;
232
233    fn sample(exit_code: i32, skipped: Option<bool>) -> ExecResult {
234        let t0 = chrono::Utc.with_ymd_and_hms(2026, 9, 27, 0, 0, 0).unwrap();
235        ExecResult {
236            result_id: "r1".into(),
237            request_id: "req".into(),
238            exec_id: None,
239            parent_result_id: None,
240            pc_id: "PC1".into(),
241            exit_code,
242            skipped,
243            stdout: String::new(),
244            stderr: String::new(),
245            started_at: t0,
246            finished_at: t0,
247            stdout_object: None,
248            stderr_object: None,
249            manifest_id: None,
250            collect_object: None,
251        }
252    }
253
254    #[test]
255    fn skipped_is_always_on_the_wire_and_absent_means_legacy() {
256        for flag in [Some(true), Some(false)] {
257            let json = serde_json::to_string(&sample(127, flag)).unwrap();
258            let expected = format!("\"skipped\":{}", flag.unwrap());
259            assert!(json.contains(&expected), "{expected} missing: {json}");
260            let back: ExecResult = serde_json::from_str(&json).unwrap();
261            assert_eq!(back.skipped, flag);
262        }
263        // An agent that predates the flag sends no key at all.
264        let json = r#"{
265            "request_id":"r","pc_id":"x","exit_code":125,
266            "stdout":"","stderr":"",
267            "started_at":"2026-05-16T00:00:00Z",
268            "finished_at":"2026-05-16T00:00:00Z"
269        }"#;
270        let legacy: ExecResult = serde_json::from_str(json).unwrap();
271        assert_eq!(legacy.skipped, None);
272    }
273
274    #[test]
275    fn reporting_follows_the_flag_and_check_projection_falls_back_for_legacy() {
276        // Reporting: only an explicit skip; legacy is read by exit code.
277        assert!(sample(125, Some(true)).is_reported_skip());
278        assert!(!sample(127, Some(false)).is_reported_skip());
279        assert!(!sample(125, None).is_reported_skip());
280
281        // Check projection: the flag decides when present...
282        assert!(sample(0, Some(true)).skips_check_projection());
283        assert!(!sample(127, Some(false)).skips_check_projection());
284        // ...and legacy keeps the pre-flag 122..=127 rule, edges included.
285        for code in [122, 123, 125, 127] {
286            assert!(sample(code, None).skips_check_projection(), "{code}");
287        }
288        for code in [0, 1, 121, 128] {
289            assert!(!sample(code, None).skips_check_projection(), "{code}");
290        }
291    }
292
293    #[test]
294    fn signature_refusal_is_recognised_by_its_derived_id_not_by_exit_123() {
295        let t0 = chrono::Utc.with_ymd_and_hms(2026, 9, 27, 0, 0, 0).unwrap();
296        let mut r = ExecResult {
297            result_id: signature_refusal_result_id("req-1", "PC1"),
298            request_id: "req-1".into(),
299            exec_id: None,
300            parent_result_id: None,
301            pc_id: "PC1".into(),
302            exit_code: EXIT_REJECTED_UNSIGNED,
303            skipped: Some(false),
304            stdout: String::new(),
305            stderr: "refused: unsigned".into(),
306            started_at: t0,
307            finished_at: t0,
308            stdout_object: None,
309            stderr_object: None,
310            manifest_id: None,
311            collect_object: None,
312        };
313        assert!(r.is_signature_refusal());
314        // A script that just exits 123 (xargs does) has an ordinary id.
315        r.result_id = "3f0e6a52-1b7c-4c1e-9d55-0d8f1f2b7a10".into();
316        assert!(!r.is_signature_refusal());
317        // The derived id with any other exit code is not a refusal either.
318        r.result_id = signature_refusal_result_id("req-1", "PC1");
319        r.exit_code = 1;
320        assert!(!r.is_signature_refusal());
321    }
322
323    #[test]
324    fn exec_result_round_trips_through_json() {
325        let t0 = chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap();
326        let t1 = chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 5).unwrap();
327        let r = ExecResult {
328            result_id: "result-uuid-1".into(),
329            request_id: "req-1".into(),
330            exec_id: Some("exec-uuid-1".into()),
331            parent_result_id: None,
332            pc_id: "pc-01".into(),
333            exit_code: 0,
334            skipped: Some(false),
335            stdout: "hello\n".into(),
336            stderr: String::new(),
337            started_at: t0,
338            finished_at: t1,
339            stdout_object: None,
340            stderr_object: None,
341            manifest_id: Some("inventory-hw".into()),
342            collect_object: None,
343        };
344        let json = serde_json::to_string(&r).unwrap();
345        let back: ExecResult = serde_json::from_str(&json).unwrap();
346        assert_eq!(back.result_id, r.result_id);
347        assert_eq!(back.request_id, r.request_id);
348        assert_eq!(back.exec_id.as_deref(), Some("exec-uuid-1"));
349        assert_eq!(back.exit_code, r.exit_code);
350        assert_eq!(back.stdout, r.stdout);
351        assert_eq!(back.started_at, t0);
352        assert_eq!(back.finished_at, t1);
353        assert_eq!(back.manifest_id.as_deref(), Some("inventory-hw"));
354    }
355
356    #[test]
357    fn exec_result_without_manifest_id_decodes() {
358        // Older agents (pre-0.13) sent ExecResult with no manifest_id field.
359        let json = r#"{
360            "request_id":"r","pc_id":"x","exit_code":0,
361            "stdout":"","stderr":"",
362            "started_at":"2026-05-16T00:00:00Z",
363            "finished_at":"2026-05-16T00:00:00Z"
364        }"#;
365        let r: ExecResult = serde_json::from_str(json).unwrap();
366        assert_eq!(r.manifest_id, None);
367    }
368
369    #[test]
370    fn exec_result_without_result_id_decodes_empty() {
371        // v0.29 / Issue #19: pre-v0.29 agents don't send `result_id`.
372        // `#[serde(default)]` decodes it as the empty string so the
373        // projector can detect "legacy payload" and call
374        // `stable_result_id()` to derive a deterministic PK.
375        let json = r#"{
376            "request_id":"r","pc_id":"x","exit_code":0,
377            "stdout":"","stderr":"",
378            "started_at":"2026-05-16T00:00:00Z",
379            "finished_at":"2026-05-16T00:00:00Z"
380        }"#;
381        let r: ExecResult = serde_json::from_str(json).unwrap();
382        assert_eq!(r.result_id, "");
383        assert!(r.exec_id.is_none());
384    }
385
386    #[test]
387    fn stable_result_id_is_deterministic_for_legacy_payload() {
388        // Gemini #65 medium fix: legacy redeliveries (same request_id +
389        // pc_id) must hash to the SAME result_id so the projector's
390        // ON CONFLICT(result_id) DO NOTHING dedupes — otherwise
391        // `executions.success_count` double-counts on JetStream ack
392        // timeouts.
393        let json = r#"{
394            "request_id":"r","pc_id":"x","exit_code":0,
395            "stdout":"","stderr":"",
396            "started_at":"2026-05-16T00:00:00Z",
397            "finished_at":"2026-05-16T00:00:00Z"
398        }"#;
399        let a: ExecResult = serde_json::from_str(json).unwrap();
400        let b: ExecResult = serde_json::from_str(json).unwrap();
401        assert_eq!(
402            a.stable_result_id(),
403            b.stable_result_id(),
404            "same legacy payload must hash to the same result_id",
405        );
406    }
407
408    #[test]
409    fn stable_result_id_differs_across_pcs_for_broadcast() {
410        // The other half: a broadcast Command published to two PCs
411        // produces two legacy ExecResults sharing one request_id but
412        // with different pc_ids. Each must get its OWN result_id so
413        // both rows persist (the whole point of Issue #19).
414        let json_a = r#"{
415            "request_id":"shared","pc_id":"pc-1","exit_code":0,
416            "stdout":"","stderr":"",
417            "started_at":"2026-05-16T00:00:00Z",
418            "finished_at":"2026-05-16T00:00:00Z"
419        }"#;
420        let json_b = r#"{
421            "request_id":"shared","pc_id":"pc-2","exit_code":0,
422            "stdout":"","stderr":"",
423            "started_at":"2026-05-16T00:00:00Z",
424            "finished_at":"2026-05-16T00:00:00Z"
425        }"#;
426        let a: ExecResult = serde_json::from_str(json_a).unwrap();
427        let b: ExecResult = serde_json::from_str(json_b).unwrap();
428        assert_ne!(
429            a.stable_result_id(),
430            b.stable_result_id(),
431            "different pc_id must produce a different result_id",
432        );
433    }
434
435    #[test]
436    fn stable_result_id_passes_through_explicit_value() {
437        // v0.29 agents always supply result_id; the helper must
438        // return that as-is (no surprise re-hashing).
439        let r = ExecResult {
440            result_id: "agent-minted-uuid".into(),
441            request_id: "r".into(),
442            exec_id: None,
443            parent_result_id: None,
444            pc_id: "x".into(),
445            exit_code: 0,
446            skipped: Some(false),
447            stdout: String::new(),
448            stderr: String::new(),
449            started_at: chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap(),
450            finished_at: chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap(),
451            stdout_object: None,
452            stderr_object: None,
453            manifest_id: None,
454            collect_object: None,
455        };
456        assert_eq!(r.stable_result_id(), "agent-minted-uuid");
457    }
458
459    #[test]
460    fn exec_result_collect_object_round_trips_and_omits_when_absent() {
461        // #219: collect_object is off the wire when None
462        // (skip_serializing_if) so pre-#219 readers stay compatible...
463        let t0 = chrono::Utc.with_ymd_and_hms(2026, 6, 15, 0, 0, 0).unwrap();
464        let mut r = ExecResult {
465            result_id: "r1".into(),
466            request_id: "req".into(),
467            exec_id: None,
468            parent_result_id: None,
469            pc_id: "PC1".into(),
470            exit_code: 0,
471            skipped: Some(false),
472            stdout: String::new(),
473            stderr: String::new(),
474            started_at: t0,
475            finished_at: t0,
476            stdout_object: None,
477            stderr_object: None,
478            manifest_id: Some("collect-diagnostics".into()),
479            collect_object: None,
480        };
481        let json = serde_json::to_string(&r).unwrap();
482        assert!(
483            !json.contains("collect_object"),
484            "collect_object must be absent when None: {json}"
485        );
486        // ...and a set key survives the round-trip.
487        r.collect_object = Some("PC1/collect-diagnostics/20260615T000000Z.zip".into());
488        let back: ExecResult = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
489        assert_eq!(
490            back.collect_object.as_deref(),
491            Some("PC1/collect-diagnostics/20260615T000000Z.zip"),
492        );
493    }
494
495    #[test]
496    fn exec_result_parent_result_id_round_trips_and_omits_when_absent() {
497        // #955: a finalize row carries `parent_result_id`; ordinary runs
498        // leave it None, and it stays off the wire so pre-#955 readers
499        // are unaffected (skip_serializing_if).
500        let t0 = chrono::Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap();
501        let mut r = ExecResult {
502            result_id: "fin-1".into(),
503            request_id: "req__finalize".into(),
504            exec_id: None,
505            parent_result_id: None,
506            pc_id: "PC1".into(),
507            exit_code: 0,
508            skipped: Some(false),
509            stdout: String::new(),
510            stderr: String::new(),
511            started_at: t0,
512            finished_at: t0,
513            stdout_object: None,
514            stderr_object: None,
515            manifest_id: Some("screenshot-collect__finalize".into()),
516            collect_object: None,
517        };
518        let json = serde_json::to_string(&r).unwrap();
519        assert!(
520            !json.contains("parent_result_id"),
521            "parent_result_id must be absent when None: {json}"
522        );
523        r.parent_result_id = Some("parent-run-uuid".into());
524        let back: ExecResult = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
525        assert_eq!(back.parent_result_id.as_deref(), Some("parent-run-uuid"));
526    }
527}