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>/<pc_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/// Synthetic exit code: the agent restarted while this command was
162/// launching or running, so what became of the script is **unknown**.
163///
164/// Reported once, at the next start, for an admitted command whose durable
165/// record says it had begun but whose outcome never reached disk. The process
166/// may still be running, may have finished, or may never have started; the
167/// agent does not relaunch it, because a second launch could repeat a side
168/// effect. Sits just below the skip block (122..=127) on purpose: the result is
169/// published with [`ExecResult::skipped`] `Some(false)`, so it is a failure in
170/// every tally, never a skip and never a success.
171pub const EXIT_RESTARTED_OUTCOME_UNKNOWN: i32 = 121;
172
173/// The deterministic `result_id` of the signature refusal for
174/// `(request_id, pc_id)` (#1165). Deterministic because a refusal is the one
175/// result that repeats — the replay re-delivers the same unverifiable command
176/// on every reconnect — so every re-publish must land on the same row. It also
177/// makes a refusal recognisable without trusting the exit code alone: see
178/// [`ExecResult::is_signature_refusal`].
179pub fn signature_refusal_result_id(request_id: &str, pc_id: &str) -> String {
180 Uuid::new_v5(
181 &Uuid::NAMESPACE_OID,
182 format!("{request_id}|{pc_id}|signature-refused").as_bytes(),
183 )
184 .to_string()
185}
186
187impl ExecResult {
188 /// Return the `result_id` if the agent supplied one (v0.29+
189 /// payloads always do), otherwise derive a stable UUIDv5 from
190 /// `(request_id, pc_id)`. The projector calls this before INSERT
191 /// so legacy payloads still get a non-empty PK, AND so that
192 /// JetStream redeliveries of the same legacy payload hash to the
193 /// same id and dedupe via `ON CONFLICT`. Per-PC fan-out stays
194 /// distinct (different `pc_id` → different hash).
195 pub fn stable_result_id(&self) -> String {
196 if !self.result_id.is_empty() {
197 return self.result_id.clone();
198 }
199 let name = format!(
200 "{LEGACY_RESULT_ID_PREFIX}{}:{}",
201 self.request_id, self.pc_id
202 );
203 Uuid::new_v5(&Uuid::NAMESPACE_OID, name.as_bytes()).to_string()
204 }
205
206 /// True when the agent reported this result as a skip. What every
207 /// result tally (and `execution_results.skipped`) counts. A legacy
208 /// result (`None`) is not one: tallies read those by exit code, exactly
209 /// as they did before the flag existed.
210 pub fn is_reported_skip(&self) -> bool {
211 self.skipped == Some(true)
212 }
213
214 /// True when this result must be kept out of the `check_status`
215 /// projection because the script never ran (#909). Follows the flag
216 /// when the agent sent one. For a legacy result (`None`) it keeps the
217 /// rule the projector applied before the flag existed: every reserved
218 /// exit code 122..=127 is dropped. A legacy agent cannot tell its own
219 /// skip from a script that really exited 126 / 127, so those real exits
220 /// stay dropped for legacy agents too — unchanged behaviour, not a new
221 /// guess. (The signature refusal is handled separately, see
222 /// [`Self::is_signature_refusal`].)
223 pub fn skips_check_projection(&self) -> bool {
224 match self.skipped {
225 Some(skipped) => skipped,
226 None => (EXIT_SKIP_UNSUPPORTED..=EXIT_SKIP_STALENESS).contains(&self.exit_code),
227 }
228 }
229
230 /// True for the agent's signature refusal (#1165): exit
231 /// [`EXIT_REJECTED_UNSIGNED`] under the refusal's derived `result_id`.
232 /// A script that merely exits 123 (xargs does, for one) carries an
233 /// ordinary random id and is not a refusal.
234 pub fn is_signature_refusal(&self) -> bool {
235 self.exit_code == EXIT_REJECTED_UNSIGNED
236 && self.result_id == signature_refusal_result_id(&self.request_id, &self.pc_id)
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use chrono::TimeZone;
244
245 fn sample(exit_code: i32, skipped: Option<bool>) -> ExecResult {
246 let t0 = chrono::Utc.with_ymd_and_hms(2026, 9, 27, 0, 0, 0).unwrap();
247 ExecResult {
248 result_id: "r1".into(),
249 request_id: "req".into(),
250 exec_id: None,
251 parent_result_id: None,
252 pc_id: "PC1".into(),
253 exit_code,
254 skipped,
255 stdout: String::new(),
256 stderr: String::new(),
257 started_at: t0,
258 finished_at: t0,
259 stdout_object: None,
260 stderr_object: None,
261 manifest_id: None,
262 collect_object: None,
263 }
264 }
265
266 #[test]
267 fn restart_outcome_unknown_is_a_failure_outside_the_skip_block() {
268 assert!(
269 !(EXIT_SKIP_UNSUPPORTED..=EXIT_SKIP_STALENESS)
270 .contains(&EXIT_RESTARTED_OUTCOME_UNKNOWN)
271 );
272 let r = sample(EXIT_RESTARTED_OUTCOME_UNKNOWN, Some(false));
273 assert!(!r.is_reported_skip());
274 assert!(!r.skips_check_projection());
275 }
276
277 #[test]
278 fn skipped_is_always_on_the_wire_and_absent_means_legacy() {
279 for flag in [Some(true), Some(false)] {
280 let json = serde_json::to_string(&sample(127, flag)).unwrap();
281 let expected = format!("\"skipped\":{}", flag.unwrap());
282 assert!(json.contains(&expected), "{expected} missing: {json}");
283 let back: ExecResult = serde_json::from_str(&json).unwrap();
284 assert_eq!(back.skipped, flag);
285 }
286 // An agent that predates the flag sends no key at all.
287 let json = r#"{
288 "request_id":"r","pc_id":"x","exit_code":125,
289 "stdout":"","stderr":"",
290 "started_at":"2026-05-16T00:00:00Z",
291 "finished_at":"2026-05-16T00:00:00Z"
292 }"#;
293 let legacy: ExecResult = serde_json::from_str(json).unwrap();
294 assert_eq!(legacy.skipped, None);
295 }
296
297 #[test]
298 fn reporting_follows_the_flag_and_check_projection_falls_back_for_legacy() {
299 // Reporting: only an explicit skip; legacy is read by exit code.
300 assert!(sample(125, Some(true)).is_reported_skip());
301 assert!(!sample(127, Some(false)).is_reported_skip());
302 assert!(!sample(125, None).is_reported_skip());
303
304 // Check projection: the flag decides when present...
305 assert!(sample(0, Some(true)).skips_check_projection());
306 assert!(!sample(127, Some(false)).skips_check_projection());
307 // ...and legacy keeps the pre-flag 122..=127 rule, edges included.
308 for code in [122, 123, 125, 127] {
309 assert!(sample(code, None).skips_check_projection(), "{code}");
310 }
311 for code in [0, 1, 121, 128] {
312 assert!(!sample(code, None).skips_check_projection(), "{code}");
313 }
314 }
315
316 #[test]
317 fn signature_refusal_is_recognised_by_its_derived_id_not_by_exit_123() {
318 let t0 = chrono::Utc.with_ymd_and_hms(2026, 9, 27, 0, 0, 0).unwrap();
319 let mut r = ExecResult {
320 result_id: signature_refusal_result_id("req-1", "PC1"),
321 request_id: "req-1".into(),
322 exec_id: None,
323 parent_result_id: None,
324 pc_id: "PC1".into(),
325 exit_code: EXIT_REJECTED_UNSIGNED,
326 skipped: Some(false),
327 stdout: String::new(),
328 stderr: "refused: unsigned".into(),
329 started_at: t0,
330 finished_at: t0,
331 stdout_object: None,
332 stderr_object: None,
333 manifest_id: None,
334 collect_object: None,
335 };
336 assert!(r.is_signature_refusal());
337 // A script that just exits 123 (xargs does) has an ordinary id.
338 r.result_id = "3f0e6a52-1b7c-4c1e-9d55-0d8f1f2b7a10".into();
339 assert!(!r.is_signature_refusal());
340 // The derived id with any other exit code is not a refusal either.
341 r.result_id = signature_refusal_result_id("req-1", "PC1");
342 r.exit_code = 1;
343 assert!(!r.is_signature_refusal());
344 }
345
346 #[test]
347 fn exec_result_round_trips_through_json() {
348 let t0 = chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap();
349 let t1 = chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 5).unwrap();
350 let r = ExecResult {
351 result_id: "result-uuid-1".into(),
352 request_id: "req-1".into(),
353 exec_id: Some("exec-uuid-1".into()),
354 parent_result_id: None,
355 pc_id: "pc-01".into(),
356 exit_code: 0,
357 skipped: Some(false),
358 stdout: "hello\n".into(),
359 stderr: String::new(),
360 started_at: t0,
361 finished_at: t1,
362 stdout_object: None,
363 stderr_object: None,
364 manifest_id: Some("inventory-hw".into()),
365 collect_object: None,
366 };
367 let json = serde_json::to_string(&r).unwrap();
368 let back: ExecResult = serde_json::from_str(&json).unwrap();
369 assert_eq!(back.result_id, r.result_id);
370 assert_eq!(back.request_id, r.request_id);
371 assert_eq!(back.exec_id.as_deref(), Some("exec-uuid-1"));
372 assert_eq!(back.exit_code, r.exit_code);
373 assert_eq!(back.stdout, r.stdout);
374 assert_eq!(back.started_at, t0);
375 assert_eq!(back.finished_at, t1);
376 assert_eq!(back.manifest_id.as_deref(), Some("inventory-hw"));
377 }
378
379 #[test]
380 fn exec_result_without_manifest_id_decodes() {
381 // Older agents (pre-0.13) sent ExecResult with no manifest_id field.
382 let json = r#"{
383 "request_id":"r","pc_id":"x","exit_code":0,
384 "stdout":"","stderr":"",
385 "started_at":"2026-05-16T00:00:00Z",
386 "finished_at":"2026-05-16T00:00:00Z"
387 }"#;
388 let r: ExecResult = serde_json::from_str(json).unwrap();
389 assert_eq!(r.manifest_id, None);
390 }
391
392 #[test]
393 fn exec_result_without_result_id_decodes_empty() {
394 // v0.29 / Issue #19: pre-v0.29 agents don't send `result_id`.
395 // `#[serde(default)]` decodes it as the empty string so the
396 // projector can detect "legacy payload" and call
397 // `stable_result_id()` to derive a deterministic PK.
398 let json = r#"{
399 "request_id":"r","pc_id":"x","exit_code":0,
400 "stdout":"","stderr":"",
401 "started_at":"2026-05-16T00:00:00Z",
402 "finished_at":"2026-05-16T00:00:00Z"
403 }"#;
404 let r: ExecResult = serde_json::from_str(json).unwrap();
405 assert_eq!(r.result_id, "");
406 assert!(r.exec_id.is_none());
407 }
408
409 #[test]
410 fn stable_result_id_is_deterministic_for_legacy_payload() {
411 // Gemini #65 medium fix: legacy redeliveries (same request_id +
412 // pc_id) must hash to the SAME result_id so the projector's
413 // ON CONFLICT(result_id) DO NOTHING dedupes — otherwise
414 // `executions.success_count` double-counts on JetStream ack
415 // timeouts.
416 let json = r#"{
417 "request_id":"r","pc_id":"x","exit_code":0,
418 "stdout":"","stderr":"",
419 "started_at":"2026-05-16T00:00:00Z",
420 "finished_at":"2026-05-16T00:00:00Z"
421 }"#;
422 let a: ExecResult = serde_json::from_str(json).unwrap();
423 let b: ExecResult = serde_json::from_str(json).unwrap();
424 assert_eq!(
425 a.stable_result_id(),
426 b.stable_result_id(),
427 "same legacy payload must hash to the same result_id",
428 );
429 }
430
431 #[test]
432 fn stable_result_id_differs_across_pcs_for_broadcast() {
433 // The other half: a broadcast Command published to two PCs
434 // produces two legacy ExecResults sharing one request_id but
435 // with different pc_ids. Each must get its OWN result_id so
436 // both rows persist (the whole point of Issue #19).
437 let json_a = r#"{
438 "request_id":"shared","pc_id":"pc-1","exit_code":0,
439 "stdout":"","stderr":"",
440 "started_at":"2026-05-16T00:00:00Z",
441 "finished_at":"2026-05-16T00:00:00Z"
442 }"#;
443 let json_b = r#"{
444 "request_id":"shared","pc_id":"pc-2","exit_code":0,
445 "stdout":"","stderr":"",
446 "started_at":"2026-05-16T00:00:00Z",
447 "finished_at":"2026-05-16T00:00:00Z"
448 }"#;
449 let a: ExecResult = serde_json::from_str(json_a).unwrap();
450 let b: ExecResult = serde_json::from_str(json_b).unwrap();
451 assert_ne!(
452 a.stable_result_id(),
453 b.stable_result_id(),
454 "different pc_id must produce a different result_id",
455 );
456 }
457
458 #[test]
459 fn stable_result_id_passes_through_explicit_value() {
460 // v0.29 agents always supply result_id; the helper must
461 // return that as-is (no surprise re-hashing).
462 let r = ExecResult {
463 result_id: "agent-minted-uuid".into(),
464 request_id: "r".into(),
465 exec_id: None,
466 parent_result_id: None,
467 pc_id: "x".into(),
468 exit_code: 0,
469 skipped: Some(false),
470 stdout: String::new(),
471 stderr: String::new(),
472 started_at: chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap(),
473 finished_at: chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap(),
474 stdout_object: None,
475 stderr_object: None,
476 manifest_id: None,
477 collect_object: None,
478 };
479 assert_eq!(r.stable_result_id(), "agent-minted-uuid");
480 }
481
482 #[test]
483 fn exec_result_collect_object_round_trips_and_omits_when_absent() {
484 // #219: collect_object is off the wire when None
485 // (skip_serializing_if) so pre-#219 readers stay compatible...
486 let t0 = chrono::Utc.with_ymd_and_hms(2026, 6, 15, 0, 0, 0).unwrap();
487 let mut r = ExecResult {
488 result_id: "r1".into(),
489 request_id: "req".into(),
490 exec_id: None,
491 parent_result_id: None,
492 pc_id: "PC1".into(),
493 exit_code: 0,
494 skipped: Some(false),
495 stdout: String::new(),
496 stderr: String::new(),
497 started_at: t0,
498 finished_at: t0,
499 stdout_object: None,
500 stderr_object: None,
501 manifest_id: Some("collect-diagnostics".into()),
502 collect_object: None,
503 };
504 let json = serde_json::to_string(&r).unwrap();
505 assert!(
506 !json.contains("collect_object"),
507 "collect_object must be absent when None: {json}"
508 );
509 // ...and a set key survives the round-trip.
510 r.collect_object = Some("PC1/collect-diagnostics/20260615T000000Z.zip".into());
511 let back: ExecResult = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
512 assert_eq!(
513 back.collect_object.as_deref(),
514 Some("PC1/collect-diagnostics/20260615T000000Z.zip"),
515 );
516 }
517
518 #[test]
519 fn exec_result_parent_result_id_round_trips_and_omits_when_absent() {
520 // #955: a finalize row carries `parent_result_id`; ordinary runs
521 // leave it None, and it stays off the wire so pre-#955 readers
522 // are unaffected (skip_serializing_if).
523 let t0 = chrono::Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap();
524 let mut r = ExecResult {
525 result_id: "fin-1".into(),
526 request_id: "req__finalize".into(),
527 exec_id: None,
528 parent_result_id: None,
529 pc_id: "PC1".into(),
530 exit_code: 0,
531 skipped: Some(false),
532 stdout: String::new(),
533 stderr: String::new(),
534 started_at: t0,
535 finished_at: t0,
536 stdout_object: None,
537 stderr_object: None,
538 manifest_id: Some("screenshot-collect__finalize".into()),
539 collect_object: None,
540 };
541 let json = serde_json::to_string(&r).unwrap();
542 assert!(
543 !json.contains("parent_result_id"),
544 "parent_result_id must be absent when None: {json}"
545 );
546 r.parent_result_id = Some("parent-run-uuid".into());
547 let back: ExecResult = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
548 assert_eq!(back.parent_result_id.as_deref(), Some("parent-run-uuid"));
549 }
550}