kanade_shared/wire/event.rs
1//! v0.30 / PR α' — agent-emitted lifecycle event published just
2//! before script spawn. Lets the backend create an `execution_results`
3//! row with `finished_at = NULL` so the SPA Activity table can show
4//! in-flight runs alongside finished ones (filterable via status).
5//!
6//! Why this exists separately from [`super::ExecResult`]: an
7//! ExecResult only lands when the script returns, which can be many
8//! minutes (or never, for stuck processes). EventStarted closes the
9//! "what's running NOW" gap without overloading ExecResult's "what
10//! came out" semantics.
11//!
12//! `result_id` is the same UUID the agent will later use on the
13//! matching ExecResult. The agent mints it in `handle_command`
14//! before publishing events.started, then threads it through to the
15//! ExecResult builder so both end up writing to the same
16//! `execution_results.result_id` row. Backend UPSERTs against this
17//! key — events.started INSERTs (or ON CONFLICT no-op), ExecResult
18//! INSERTs-or-UPDATEs in one shot.
19//!
20//! Offline handling: published via the file-based outbox same as
21//! ExecResult (see `kanade-agent::events_outbox`). Survives broker
22//! outages + agent restarts; backend sees `started → finished` as a
23//! coherent pair once the agent reconnects and both outboxes drain.
24
25use chrono::{DateTime, Utc};
26use serde::{Deserialize, Serialize};
27
28#[derive(Serialize, Deserialize, Debug, Clone)]
29pub struct EventStarted {
30 /// Agent-minted UUID, identical to the `ExecResult.result_id`
31 /// that will follow when the script finishes. Backend uses it
32 /// as the UPSERT key so events.started and the matching
33 /// ExecResult coalesce into a single `execution_results` row
34 /// regardless of arrival order.
35 pub result_id: String,
36 /// NATS reply token for this run. Mirrors
37 /// [`super::ExecResult::request_id`]. Carried here so the
38 /// events projector can populate `execution_results.request_id`
39 /// (NOT NULL in the schema) at insert time without waiting for
40 /// the matching ExecResult.
41 pub request_id: String,
42 /// Deployment / scheduler-fire UUID. Same value as
43 /// [`super::Command::exec_id`].
44 pub exec_id: String,
45 /// PC reporting the start.
46 pub pc_id: String,
47 /// Wall-clock instant the agent took just before
48 /// `tokio::process::Command::spawn()`. Same value will end up
49 /// on the matching `ExecResult.started_at`.
50 pub started_at: DateTime<Utc>,
51 /// `Manifest.id` for the running script. Useful for the SPA
52 /// Activity table so each running row knows what's running
53 /// without a lookup.
54 pub manifest_id: String,
55 /// Pinned manifest version. Same field as `Command.version`.
56 pub version: String,
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62 use chrono::TimeZone;
63
64 #[test]
65 fn event_started_round_trips_through_json() {
66 let t = Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, 0).unwrap();
67 let e = EventStarted {
68 result_id: "result-uuid-1".into(),
69 request_id: "req-1".into(),
70 exec_id: "exec-uuid-1".into(),
71 pc_id: "minipc".into(),
72 started_at: t,
73 manifest_id: "inventory-hw".into(),
74 version: "1.0.0".into(),
75 };
76 let json = serde_json::to_string(&e).unwrap();
77 let back: EventStarted = serde_json::from_str(&json).unwrap();
78 assert_eq!(back.result_id, e.result_id);
79 assert_eq!(back.request_id, e.request_id);
80 assert_eq!(back.exec_id, e.exec_id);
81 assert_eq!(back.pc_id, e.pc_id);
82 assert_eq!(back.started_at, t);
83 assert_eq!(back.manifest_id, e.manifest_id);
84 assert_eq!(back.version, e.version);
85 }
86}