Skip to main content

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}