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}