Skip to main content

uptrakit_wire/
payloads.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use time::UtcDateTime;
5use uuid::Uuid;
6
7use super::capabilities::{Capability, EnrollmentStatus};
8use super::shared_types::{DisconnectReason, UpdateFinalStatus};
9use crate::serde_helpers::{duration_seconds, option_duration_seconds, utc_datetime_millis};
10use uptrakit_shared_types::{
11    DiscoveredSoftware, OutputStreamType, PluginTypeId, ReleaseInfo, SecretString, UpdateCategory,
12};
13
14/// Payload for ping messages.
15#[non_exhaustive]
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct PingPayload {
18    /// Timestamp when the service sent the ping.
19    pub service_ts: super::shared_types::Timestamp,
20}
21
22impl PingPayload {
23    /// Creates a new `PingPayload` with the given service timestamp.
24    pub fn new(service_ts: super::shared_types::Timestamp) -> Self {
25        Self { service_ts }
26    }
27}
28
29/// Payload for pong messages.
30#[non_exhaustive]
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct PongPayload {
33    /// Original timestamp from the service's ping.
34    pub service_ts: super::shared_types::Timestamp,
35    /// Timestamp when the controller processed the ping.
36    pub controller_ts: super::shared_types::Timestamp,
37}
38
39impl PongPayload {
40    /// Creates a new `PongPayload` with the given service and controller timestamps.
41    pub fn new(
42        service_ts: super::shared_types::Timestamp,
43        controller_ts: super::shared_types::Timestamp,
44    ) -> Self {
45        Self {
46            service_ts,
47            controller_ts,
48        }
49    }
50}
51
52/// Information about the host machine running the agent.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct HostInfo {
55    /// Persistent machine identifier (e.g. `/etc/machine-id` on Linux, `IOPlatformUUID` on macOS).
56    pub machine_id: String,
57    /// Operating system type (e.g. "linux", "macos").
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub os_type: Option<String>,
60    /// Operating system version (e.g. "Ubuntu 24.04 LTS").
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub os_version: Option<String>,
63    /// CPU architecture (e.g. "x86_64", "aarch64").
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub architecture: Option<String>,
66    /// Hostname reported by the agent/host machine.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub hostname: Option<String>,
69    /// Network address of the host (SSH target address for SSH agent hosts).
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub ip_address: Option<String>,
72    /// Agent-local UUID assigned to this host at bootstrap time.
73    ///
74    /// When present, the controller uses this as `hosts.id` when creating a
75    /// new row, ensuring agent and controller share the same UUID. This is
76    /// required for plugin FK operations (e.g. Proxmox host mapping) that
77    /// reference `hosts.id` before the controller has generated its own UUID.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub agent_host_id: Option<Uuid>,
80    /// Agent-probed host features (e.g. `["posix_shell", "privilege_escalation", "systemd"]`).
81    ///
82    /// `None` for legacy agents that predate feature reporting. Uses `Vec<String>`
83    /// (not `BTreeSet<HostFeature>`) on the wire for forward-compatibility: if a
84    /// newer agent reports a feature the controller doesn't know, it is stored
85    /// losslessly and ignored by `HostCapabilities` parsing.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub features: Option<Vec<String>>,
88}
89
90/// Payload for service enrollment request.
91///
92/// Used by both agents and MQTT services. Host information is reported
93/// separately via [`ReportHostsPayload`] after authentication.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct EnrollPayload {
96    pub hostname: String,
97    pub friendly_name: String,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub enrollment_token: Option<SecretString>,
100    /// Capabilities this service supports.
101    ///
102    /// The controller persists these in the `services.capabilities` column and
103    /// derives behavioral defaults from the resulting [`ServiceProfile`](crate::ServiceProfile).
104    pub capabilities: BTreeSet<Capability>,
105    /// The binary/crate name of the enrolling service (e.g., `"uptrakit-agent-ssh"`).
106    ///
107    /// Derived from `env!("CARGO_PKG_NAME")` at compile time. Used for UI
108    /// display, extension conflict detection, and distinguishing service binaries.
109    pub service_app_name: String,
110}
111
112/// Payload for requesting a client certificate after approval.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct RequestCertificatePayload {
115    /// PEM-encoded Certificate Signing Request.
116    pub csr_pem: String,
117}
118
119/// Payload for requesting certificate renewal (mTLS-authenticated services).
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct RenewCertificatePayload {
122    /// PEM-encoded Certificate Signing Request with CN=service_id.
123    pub csr_pem: String,
124}
125
126/// Payload for reporting host information (sent by authenticated agents on connect).
127///
128/// Supports multiple hosts per message, enabling a single service instance
129/// (e.g. a future SSH-backed agent) to manage several remote hosts.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct ReportHostsPayload {
132    /// One or more host machines managed by this service.
133    pub hosts: Vec<HostInfo>,
134    /// Agent binary version (e.g., "0.0.1").
135    pub agent_version: String,
136    /// Capabilities advertised by this service.
137    ///
138    /// The controller computes the agreed set as the intersection of this set
139    /// with its own capabilities, considering only typed (known) variants.
140    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
141    pub capabilities: BTreeSet<Capability>,
142}
143
144/// Payload for enrollment confirmation.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct EnrolledPayload {
147    pub service_id: Uuid,
148    pub enrollment_secret: SecretString,
149    pub status: EnrollmentStatus,
150}
151
152/// Payload for approval notification.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct ApprovedPayload {
155    pub service_id: Uuid,
156}
157
158/// Payload for rejection notification.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct RejectedPayload {
161    pub service_id: Uuid,
162}
163
164/// Payload for issued certificate.
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166pub struct CertificatePayload {
167    pub cert_pem: String,
168    /// Certificate "not valid after" timestamp.
169    #[serde(with = "utc_datetime_millis")]
170    pub not_after: UtcDateTime,
171}
172
173/// Payload for service runtime settings pushed by the controller.
174///
175/// Used for both agents and MQTT services. `shutdown_timeout` is
176/// present for agents and `None` for MQTT services.
177#[non_exhaustive]
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct ServiceSettingsPayload {
180    pub renewal_window_hours: u16,
181    #[serde(default)]
182    pub ca_bundle_hash: String,
183    /// Capabilities advertised by the controller.
184    ///
185    /// The service computes the agreed set as the intersection of this set
186    /// with its own capabilities, considering only typed (known) variants.
187    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
188    pub capabilities: BTreeSet<Capability>,
189    /// Per-page item-count limits for paginated service-to-controller reports.
190    ///
191    /// Services must honor these limits when splitting large `report_hosts`,
192    /// `discovery_results`, `version_check_results`, and
193    /// `batch_update_result` payloads across pages.
194    #[serde(default, skip_serializing_if = "ReportPageLimits::is_default")]
195    pub report_page_limits: ReportPageLimits,
196    /// Maximum time to wait for in-flight operations during shutdown.
197    /// Present for agents, absent for MQTT services.
198    ///
199    /// Wire field name: `shutdown_timeout_seconds` (kept for backward compatibility).
200    #[serde(
201        default,
202        skip_serializing_if = "Option::is_none",
203        with = "option_duration_seconds",
204        rename = "shutdown_timeout_seconds"
205    )]
206    pub shutdown_timeout: Option<std::time::Duration>,
207    /// How often the service should send ping messages.
208    /// Controller-managed; derived from per-service DB override or service-type default.
209    #[serde(with = "duration_seconds")]
210    pub ping_interval: std::time::Duration,
211    /// Tenant UUID that this service belongs to.
212    ///
213    /// `None` for system services (MQTT, scheduler) which are not
214    /// tenant-scoped. Present for tenant-scoped agents so they can
215    /// include the tenant identity in external provisioning operations
216    /// (e.g. PVE API credential naming).
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub tenant_id: Option<Uuid>,
219    /// SPIFFE trust domain for Service identity URIs.
220    ///
221    /// Empty string when the Controller has no trust domain configured.
222    /// Agent falls back to the dialed hostname for SPIFFE SAN generation.
223    #[serde(default, skip_serializing_if = "String::is_empty")]
224    pub trust_domain: String,
225}
226
227impl ServiceSettingsPayload {
228    /// Creates a new [`ServiceSettingsPayload`] with the required fields.
229    ///
230    /// Optional fields default to: `ca_bundle_hash` = empty, `capabilities` = empty,
231    /// `report_page_limits` = default, `shutdown_timeout` = `None`,
232    /// `tenant_id` = `None`, `trust_domain` = empty.
233    pub fn new(renewal_window_hours: u16, ping_interval: std::time::Duration) -> Self {
234        Self {
235            renewal_window_hours,
236            ca_bundle_hash: String::new(),
237            capabilities: std::collections::BTreeSet::new(),
238            report_page_limits: ReportPageLimits::default(),
239            shutdown_timeout: None,
240            ping_interval,
241            tenant_id: None,
242            trust_domain: String::new(),
243        }
244    }
245
246    /// Sets the CA bundle hash.
247    #[must_use]
248    pub fn with_ca_bundle_hash(mut self, ca_bundle_hash: String) -> Self {
249        self.ca_bundle_hash = ca_bundle_hash;
250        self
251    }
252
253    /// Sets the controller capability set.
254    #[must_use]
255    pub fn with_capabilities(mut self, capabilities: impl IntoIterator<Item = Capability>) -> Self {
256        self.capabilities = capabilities.into_iter().collect();
257        self
258    }
259
260    /// Sets the report page limits.
261    #[must_use]
262    pub fn with_report_page_limits(mut self, report_page_limits: ReportPageLimits) -> Self {
263        self.report_page_limits = report_page_limits;
264        self
265    }
266
267    /// Sets the graceful-shutdown timeout for agent services.
268    #[must_use]
269    pub fn with_shutdown_timeout(mut self, shutdown_timeout: std::time::Duration) -> Self {
270        self.shutdown_timeout = Some(shutdown_timeout);
271        self
272    }
273
274    /// Sets the tenant UUID for tenant-scoped services.
275    #[must_use]
276    pub fn with_tenant_id(mut self, tenant_id: Uuid) -> Self {
277        self.tenant_id = Some(tenant_id);
278        self
279    }
280
281    /// Sets the SPIFFE trust domain advertised to connecting services.
282    #[must_use]
283    pub fn with_trust_domain(mut self, trust_domain: String) -> Self {
284        self.trust_domain = trust_domain;
285        self
286    }
287}
288
289/// Per-page item-count limits for paginated report payloads.
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291pub struct ReportPageLimits {
292    /// Maximum `hosts` items per `report_hosts` page.
293    pub report_hosts: u32,
294    /// Maximum `results` items per `version_check_results` page.
295    pub version_check_results: u32,
296    /// Maximum `results` items per `discovery_results` page.
297    pub discovery_results: u32,
298    /// Maximum `results` items per `batch_update_result` page.
299    pub batch_update_results: u32,
300}
301
302impl ReportPageLimits {
303    /// Returns `true` when all fields match the default wire limits.
304    pub fn is_default(&self) -> bool {
305        self == &Self::default()
306    }
307}
308
309impl Default for ReportPageLimits {
310    fn default() -> Self {
311        Self {
312            report_hosts: crate::limits::MAX_REPORT_HOSTS as u32,
313            version_check_results: crate::limits::MAX_VERSION_CHECK_RESULTS as u32,
314            discovery_results: crate::limits::MAX_DISCOVERY_PLUGIN_RESULTS as u32,
315            batch_update_results: crate::limits::MAX_BATCH_UPDATE_RESULTS as u32,
316        }
317    }
318}
319
320/// Payload for CA bundle update notification.
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322pub struct CaBundleUpdatedPayload {
323    pub ca_bundle_pem: String,
324}
325
326/// Payload for requesting immediate certificate renewal from services.
327///
328/// Sent by the controller after CA rotation or backend URL change to prompt
329/// all connected services to renew their certificates with the new CA.
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331pub struct RequestCertRenewalPayload {
332    /// Human-readable reason for the renewal request.
333    pub reason: String,
334}
335
336/// Payload for server restarting notification.
337///
338/// Sent by the controller during graceful shutdown to notify connected services
339/// that the server is restarting. Services should expect the connection to close
340/// and reconnect automatically.
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342pub struct ServerRestartingPayload {
343    /// Human-readable reason for the restart.
344    pub reason: String,
345}
346
347/// Payload for requesting version checks from the agent.
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349pub struct CheckVersionsPayload {
350    /// The machine_id of the host to check versions on.
351    ///
352    /// For the regular agent (one service = one host), the agent validates that
353    /// this matches its own machine_id as a defensive sanity check.
354    /// For the SSH agent (one service = N remote hosts), the agent uses this
355    /// field to look up the correct SSH credentials and route the operation to
356    /// the right remote host.
357    pub host_machine_id: String,
358    /// List of software items to check.
359    pub assignments: Vec<VersionCheckAssignment>,
360}
361
362/// A plugin assignment for a specific role in a version check or update.
363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
364pub struct PluginAssignment {
365    /// The plugin type (e.g. github_releases, apt, homebrew).
366    pub plugin_type: PluginTypeId,
367    /// Package identifier for this role's plugin.
368    pub package_identifier: String,
369    /// Merged plugin config (base + override).
370    pub config: serde_json::Value,
371}
372
373/// A single software item to check for installed version and/or latest version.
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375pub struct VersionCheckAssignment {
376    /// Software item ID.
377    pub software_item_id: Uuid,
378    /// Human-readable name for logging.
379    pub name: String,
380    /// Plugin for the detect_version role.
381    /// None if no detect_version plugin is configured for this host-software pair.
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub detect_version: Option<PluginAssignment>,
384    /// Plugin for the fetch_releases role — only included for agent-side plugins
385    /// (i.e., plugins without ControllerSideFetchReleases or with execution_site = agent).
386    /// Controller-side fetch_releases is handled by the scheduler, not sent to the agent.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub fetch_releases: Option<PluginAssignment>,
389    /// Host software item ID for routing results to the host_software_items table.
390    /// When set, this assignment is for a host-managed software item rather than
391    /// a targeted software item.
392    #[serde(
393        default,
394        skip_serializing_if = "Option::is_none",
395        alias = "host_package_id"
396    )]
397    pub host_software_item_id: Option<Uuid>,
398}
399
400/// Payload for version check results from the agent.
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
402pub struct VersionCheckResultsPayload {
403    /// Results for each checked software item.
404    pub results: Vec<VersionCheckResult>,
405}
406
407/// Result of a single version check.
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
409pub struct VersionCheckResult {
410    /// Software item ID.
411    pub software_item_id: Uuid,
412    /// Detected installed version, if any.
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub installed_version: Option<String>,
415    /// Latest available version from the package index, if resolved locally
416    /// by the agent (e.g., Homebrew). Absent for plugins whose latest
417    /// version is resolved on the controller side.
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub latest_version: Option<String>,
420    /// Error message if detection failed.
421    #[serde(skip_serializing_if = "Option::is_none")]
422    pub error: Option<String>,
423    /// Classification of the available update (e.g. security, bugfix).
424    /// Defaults to `Unknown` when the plugin cannot classify the update.
425    #[serde(default)]
426    pub update_category: UpdateCategory,
427    /// Host software item ID for routing results to the host_software_items table.
428    /// Mirrors the value from the corresponding [`VersionCheckAssignment`].
429    #[serde(
430        default,
431        skip_serializing_if = "Option::is_none",
432        alias = "host_package_id"
433    )]
434    pub host_software_item_id: Option<Uuid>,
435    /// Human-readable installed version for display when `installed_version`
436    /// is opaque (e.g. a Docker SHA256 digest → the image publish date).
437    /// Set by the agent from `BatchDetectResult.display_version`.
438    /// `None` when the plugin does not provide a display version.
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub installed_display_version: Option<String>,
441    /// When `true`, the agent is not yet ready to report a meaningful version
442    /// for this item (e.g. a self-update is in progress and the binary has not
443    /// restarted yet). The controller should treat this as "check again later"
444    /// rather than clearing the installed version.
445    ///
446    /// `None` / absent means "ready" for wire backward compatibility.
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub not_ready: Option<bool>,
449}
450
451// --- Update execution messages ---
452
453/// Controller -> Agent: Trigger an update.
454#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
455pub struct ExecuteUpdatePayload {
456    /// The machine_id of the host to run the update on.
457    ///
458    /// For the regular agent (one service = one host), the agent validates that
459    /// this matches its own machine_id as a defensive sanity check.
460    /// For the SSH agent (one service = N remote hosts), the agent uses this
461    /// field to look up the correct SSH credentials and route the operation to
462    /// the right remote host.
463    pub host_machine_id: String,
464    pub update_history_id: Uuid,
465    pub software_item_id: Uuid,
466    pub software_item_name: String,
467    pub to_version: String,
468    /// Plugin for the detect_version role (for before/after installed-version detection).
469    /// Absent when no detect_version plugin is configured for this assignment.
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub detect_version_plugin: Option<PluginAssignment>,
472    /// Plugin for the execute_update role.
473    pub execute_update_plugin: PluginAssignment,
474    /// Pre-update hook plugins to execute before the update, ordered by priority.
475    #[serde(default, skip_serializing_if = "Vec::is_empty")]
476    pub pre_update_hook_plugins: Vec<PluginAssignment>,
477    /// Post-update hook plugins to execute after the update, ordered by priority.
478    #[serde(default, skip_serializing_if = "Vec::is_empty")]
479    pub post_update_hook_plugins: Vec<PluginAssignment>,
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub release_info: Option<ReleaseInfo>,
482    /// Timeout for the update execution.
483    ///
484    /// Wire field name: `timeout_seconds` (kept for backward compatibility).
485    #[serde(
486        with = "duration_seconds",
487        rename = "timeout_seconds",
488        default = "super::shared_types::default_update_timeout"
489    )]
490    pub timeout: std::time::Duration,
491    /// When `true`, the agent allocates a PTY and keeps stdin open for forwarding.
492    ///
493    /// Requires the agent to advertise the `InteractiveUpdates` capability.
494    /// Defaults to `false` for backward compatibility with older agents.
495    #[serde(default)]
496    pub interactive: bool,
497}
498
499/// Agent -> Controller: Update is starting.
500#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
501pub struct UpdateStartedPayload {
502    pub update_history_id: Uuid,
503    #[serde(skip_serializing_if = "Option::is_none")]
504    pub from_version: Option<String>,
505    /// Whether a PTY was actually allocated for this update.
506    /// `false` for non-interactive updates or when PTY allocation failed.
507    /// Old agents that do not send this field will deserialize as `false`.
508    #[serde(default)]
509    pub interactive: bool,
510}
511
512/// Agent -> Controller: Streaming output line.
513#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
514pub struct UpdateOutputPayload {
515    pub update_history_id: Uuid,
516    pub output: String,
517    #[serde(default)]
518    pub stream: OutputStreamType,
519}
520
521/// Agent -> Controller: Final result of update execution.
522#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
523pub struct UpdateResultPayload {
524    pub update_history_id: Uuid,
525    pub status: UpdateFinalStatus,
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub from_version: Option<String>,
528    #[serde(skip_serializing_if = "Option::is_none")]
529    pub to_version: Option<String>,
530    pub output: String,
531    #[serde(skip_serializing_if = "Option::is_none")]
532    pub error: Option<String>,
533    /// When `true`, the agent signals that this update can be resumed after
534    /// a restart (e.g. the update script supports idempotent re-entry or the
535    /// agent is mid-self-update and will re-attach on reconnect).
536    ///
537    /// `None` / absent means "not resumable" for wire backward compatibility.
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub resumable: Option<bool>,
540}
541
542// --- Batch update messages ---
543
544/// Controller → Agent: execute a batch update of software items.
545///
546/// Groups multiple items under a single plugin type so the agent can
547/// run a single bulk command (e.g., `apt-get upgrade`, `brew upgrade`).
548#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
549pub struct ExecuteBatchUpdatePayload {
550    /// The machine_id of the host to run the update on.
551    pub host_machine_id: String,
552    /// Unique identifier for this batch operation.
553    pub batch_id: Uuid,
554    /// Plugin type for all items in this batch.
555    pub plugin_type: PluginTypeId,
556    /// Merged plugin configuration.
557    pub plugin_config: serde_json::Value,
558    /// Individual items to update.
559    pub updates: Vec<BatchUpdateItem>,
560    /// Pre-update hook plugins to execute before the batch, ordered by priority.
561    #[serde(default, skip_serializing_if = "Vec::is_empty")]
562    pub pre_update_hook_plugins: Vec<PluginAssignment>,
563    /// Post-update hook plugins to execute after the batch, ordered by priority.
564    #[serde(default, skip_serializing_if = "Vec::is_empty")]
565    pub post_update_hook_plugins: Vec<PluginAssignment>,
566    /// Timeout for the entire batch operation.
567    ///
568    /// Wire field name: `timeout_seconds` (kept for backward compatibility).
569    #[serde(
570        with = "duration_seconds",
571        rename = "timeout_seconds",
572        default = "super::shared_types::default_update_timeout"
573    )]
574    pub timeout: std::time::Duration,
575    /// When `true`, the agent allocates a PTY and keeps stdin open for forwarding.
576    ///
577    /// Requires the agent to advertise the `InteractiveUpdates` capability.
578    /// Defaults to `false` for backward compatibility with older agents.
579    #[serde(default)]
580    pub interactive: bool,
581}
582
583/// A single software item within a batch update request.
584#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
585pub struct BatchUpdateItem {
586    /// Host software item entity ID.
587    #[serde(alias = "host_package_id")]
588    pub host_software_item_id: Uuid,
589    /// Update history record ID (pre-created by the controller).
590    pub update_history_id: Uuid,
591    /// Plugin-specific package identifier (e.g., APT package name).
592    pub package_identifier: String,
593    /// Target version to install.
594    pub to_version: String,
595    /// Optional release metadata from the upstream source.
596    #[serde(default, skip_serializing_if = "Option::is_none")]
597    pub release_info: Option<ReleaseInfo>,
598}
599
600/// Agent → Controller: result of a batch update.
601#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
602pub struct BatchUpdateResultPayload {
603    /// Batch ID matching the request.
604    pub batch_id: Uuid,
605    /// Per-item results.
606    pub results: Vec<BatchUpdateItemResult>,
607}
608
609/// Result of updating a single item within a batch operation.
610#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
611pub struct BatchUpdateItemResult {
612    /// Host software item entity ID.
613    #[serde(alias = "host_package_id")]
614    pub host_software_item_id: Uuid,
615    /// Update history record ID.
616    pub update_history_id: Uuid,
617    /// Final status of this item's update.
618    pub status: UpdateFinalStatus,
619    /// Accumulated output from the update.
620    pub output: String,
621    /// Detected installed version after the update (if detection succeeded).
622    #[serde(default, skip_serializing_if = "Option::is_none")]
623    pub installed_version: Option<String>,
624    /// Error message if the update failed.
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub error: Option<String>,
627}
628
629// --- Remote update freeze ---
630
631/// Controller → Agent: enable or disable the update freeze.
632///
633/// When `enabled` is `true`, the agent creates its freeze file, which blocks
634/// `ExecuteUpdate` and `ExecuteBatchUpdate` messages until the file
635/// is removed (either via a subsequent `SetUpdateFreeze { enabled: false }`
636/// message, or manually on the host via `rm <freeze-file>`).
637///
638/// This message is safe for NATS publication — it contains no credentials.
639#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
640pub struct SetUpdateFreezePayload {
641    /// Whether to enable (`true`) or disable (`false`) the freeze.
642    pub enabled: bool,
643    /// Optional human-readable reason for the freeze (audit trail).
644    #[serde(default, skip_serializing_if = "Option::is_none")]
645    pub reason: Option<String>,
646}
647
648// --- Graceful shutdown messages ---
649
650/// Service -> Controller: Notification before disconnecting.
651#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
652pub struct DisconnectingPayload {
653    pub reason: DisconnectReason,
654}
655
656impl DisconnectingPayload {
657    /// Create a `DisconnectingPayload` with the given reason.
658    pub fn new(reason: DisconnectReason) -> Self {
659        Self { reason }
660    }
661}
662
663// =============================================================================
664// Capability Management Payloads
665// =============================================================================
666
667/// Payload sent by every service on connect to declare its capabilities.
668///
669/// Sent from `on_connected` before any other messages. The controller uses
670/// this as the authoritative source for capability detection on the current
671/// session and persists the capability set to the DB.
672#[non_exhaustive]
673#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
674pub struct RegisterPayload {
675    /// Capabilities declared by this service instance.
676    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
677    pub capabilities: BTreeSet<Capability>,
678    /// Runtime instance identity for restart-vs-reconnect detection.
679    ///
680    /// Optional for mixed-version compatibility: legacy services omit this
681    /// field and are treated as service-scoped (not instance-scoped).
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub runtime_instance_id: Option<Uuid>,
684}
685
686impl RegisterPayload {
687    /// Create a new [`RegisterPayload`] with the given capability set.
688    ///
689    /// # Example
690    ///
691    /// ```
692    /// use std::collections::BTreeSet;
693    /// use uptrakit_wire::{Capability, RegisterPayload};
694    ///
695    /// let payload = RegisterPayload::new([Capability::SoftwareDiscovery, Capability::UpdateHooks]);
696    /// assert!(payload.capabilities.contains(&Capability::SoftwareDiscovery));
697    /// ```
698    pub fn new(capabilities: impl IntoIterator<Item = Capability>) -> Self {
699        Self {
700            capabilities: capabilities.into_iter().collect(),
701            runtime_instance_id: None,
702        }
703    }
704
705    /// Set a runtime instance id on this register payload.
706    #[must_use]
707    pub fn with_runtime_instance_id(mut self, runtime_instance_id: Uuid) -> Self {
708        self.runtime_instance_id = Some(runtime_instance_id);
709        self
710    }
711}
712
713// =============================================================================
714// Service Config Store Payloads
715// =============================================================================
716
717/// A single stored service config entry, delivered to the service.
718///
719/// Sensitive values are already decrypted by the controller before delivery.
720#[non_exhaustive]
721#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
722pub struct ServiceConfigEntry {
723    /// Tenant this entry belongs to, or `None` for global entries.
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub tenant_id: Option<Uuid>,
726    /// Entry key (e.g. `"clients.{uuid}"`).
727    pub key: String,
728    /// Entry value (plaintext JSON; controller decrypts before delivery).
729    pub value: serde_json::Value,
730}
731
732impl ServiceConfigEntry {
733    /// Create a new `ServiceConfigEntry`.
734    pub fn new(tenant_id: Option<Uuid>, key: String, value: serde_json::Value) -> Self {
735        Self {
736            tenant_id,
737            key,
738            value,
739        }
740    }
741}
742
743/// Identifies a service config entry by scope and key (used in delete notifications).
744#[non_exhaustive]
745#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
746pub struct ServiceConfigKey {
747    /// Tenant this entry belongs to, or `None` for global entries.
748    #[serde(default, skip_serializing_if = "Option::is_none")]
749    pub tenant_id: Option<Uuid>,
750    /// Entry key.
751    pub key: String,
752}
753
754impl ServiceConfigKey {
755    /// Create a new `ServiceConfigKey`.
756    pub fn new(tenant_id: Option<Uuid>, key: String) -> Self {
757        Self { tenant_id, key }
758    }
759}
760
761/// Service → Controller: write or update a config entry.
762///
763/// The controller upserts the entry in `tenant_service_config` (when
764/// `tenant_id` is set) or `global_service_config` (when `None`), encrypts
765/// the value if `sensitive` is `true`, ACKs the operation, and broadcasts
766/// `ServiceConfigUpdated` to all other connected instances of the same
767/// `service_app_name`.
768#[non_exhaustive]
769#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
770pub struct StoreServiceConfigPayload {
771    /// Correlation ID for the `ServiceConfigAck` response.
772    pub request_id: String,
773    /// Tenant scope. `None` = global scope.
774    #[serde(default, skip_serializing_if = "Option::is_none")]
775    pub tenant_id: Option<Uuid>,
776    /// Config key (e.g. `"clients.{uuid}"`).
777    pub key: String,
778    /// Config value (plaintext JSON; controller encrypts at rest if `sensitive`).
779    pub value: serde_json::Value,
780    /// When `true`, the controller stores the value using `EncryptedString`.
781    #[serde(default)]
782    pub sensitive: bool,
783}
784
785impl StoreServiceConfigPayload {
786    /// Create a new `StoreServiceConfigPayload`.
787    pub fn new(
788        request_id: String,
789        tenant_id: Option<Uuid>,
790        key: String,
791        value: serde_json::Value,
792        sensitive: bool,
793    ) -> Self {
794        Self {
795            request_id,
796            tenant_id,
797            key,
798            value,
799            sensitive,
800        }
801    }
802}
803
804/// Service → Controller: delete a config entry.
805///
806/// The controller deletes the entry, ACKs, and broadcasts `ServiceConfigUpdated`
807/// to all other connected instances of the same `service_app_name`.
808#[non_exhaustive]
809#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
810pub struct DeleteServiceConfigPayload {
811    /// Correlation ID for the `ServiceConfigAck` response.
812    pub request_id: String,
813    /// Tenant scope. `None` = global scope.
814    #[serde(default, skip_serializing_if = "Option::is_none")]
815    pub tenant_id: Option<Uuid>,
816    /// Config key to delete.
817    pub key: String,
818}
819
820impl DeleteServiceConfigPayload {
821    /// Create a new `DeleteServiceConfigPayload`.
822    pub fn new(request_id: String, tenant_id: Option<Uuid>, key: String) -> Self {
823        Self {
824            request_id,
825            tenant_id,
826            key,
827        }
828    }
829}
830
831/// Controller → Service: acknowledgment of a `StoreServiceConfig` or
832/// `DeleteServiceConfig` operation.
833#[non_exhaustive]
834#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
835pub struct ServiceConfigAckPayload {
836    /// Correlation ID matching the request.
837    pub request_id: String,
838    /// `true` if the operation succeeded.
839    pub success: bool,
840    /// Error message when `success` is `false`.
841    #[serde(default, skip_serializing_if = "Option::is_none")]
842    pub error: Option<String>,
843}
844
845impl ServiceConfigAckPayload {
846    /// Create a new success ACK.
847    pub fn success(request_id: String) -> Self {
848        Self {
849            request_id,
850            success: true,
851            error: None,
852        }
853    }
854
855    /// Create a new error ACK.
856    pub fn error(request_id: String, error: String) -> Self {
857        Self {
858            request_id,
859            success: false,
860            error: Some(error),
861        }
862    }
863}
864
865/// Controller → Service: initial delivery of all stored config entries.
866///
867/// Sent once after the service authenticates (after credential delivery).
868/// The service should use this as its authoritative in-memory state.
869#[non_exhaustive]
870#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
871pub struct ServiceConfigDeliveryPayload {
872    /// All config entries stored for this `service_app_name`.
873    pub entries: Vec<ServiceConfigEntry>,
874}
875
876impl ServiceConfigDeliveryPayload {
877    /// Create a new `ServiceConfigDeliveryPayload`.
878    pub fn new(entries: Vec<ServiceConfigEntry>) -> Self {
879        Self { entries }
880    }
881}
882
883/// Controller → Service: incremental config update notification.
884///
885/// Pushed to all connected instances of the same `service_app_name` when
886/// any instance stores or deletes a config entry. Services should apply
887/// these changes to their in-memory state atomically.
888#[non_exhaustive]
889#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
890pub struct ServiceConfigUpdatedPayload {
891    /// Entries that were inserted or updated (with decrypted values).
892    #[serde(default, skip_serializing_if = "Vec::is_empty")]
893    pub changed: Vec<ServiceConfigEntry>,
894    /// Keys that were deleted.
895    #[serde(default, skip_serializing_if = "Vec::is_empty")]
896    pub deleted: Vec<ServiceConfigKey>,
897}
898
899impl ServiceConfigUpdatedPayload {
900    /// Create a new `ServiceConfigUpdatedPayload`.
901    pub fn new(changed: Vec<ServiceConfigEntry>, deleted: Vec<ServiceConfigKey>) -> Self {
902        Self { changed, deleted }
903    }
904}
905
906// =============================================================================
907// Infrastructure Credential Payloads
908// =============================================================================
909
910/// Infrastructure credentials for services that advertise credential capabilities.
911///
912/// Fields are populated based on the service's capability set:
913///   - `database_access` → `db_url` is set
914///   - `nats_access` → `nats_url` is set (if controller has NATS)
915///   - `master_key_access` → `master_key_hex` is set (if encryption enabled)
916///
917/// **Security**: This payload contains highly sensitive credentials. It must
918/// NEVER be published to NATS or any external transport. It is delivered
919/// exclusively over the authenticated WebSocket connection.
920#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
921pub struct ServiceCredentialsPayload {
922    /// Database connection URL. Present when the service has `database_access`.
923    #[serde(skip_serializing_if = "Option::is_none")]
924    pub db_url: Option<SecretString>,
925    /// Master encryption key as 64-char hex. Present when the service has
926    /// `master_key_access` and encryption is enabled on the controller.
927    #[serde(skip_serializing_if = "Option::is_none")]
928    pub master_key_hex: Option<SecretString>,
929    /// NATS server URL. Present when the service has `nats_access` and
930    /// NATS is configured on the controller.
931    #[serde(skip_serializing_if = "Option::is_none")]
932    pub nats_url: Option<String>,
933}
934
935/// Request from an external component (e.g. scheduler) for the controller to
936/// perform CA certificate rotation.
937///
938/// Published via NATS to `uptrakit.events.controller` subject. Handled by
939/// triggering `ca_rotation_trigger.notify_one()` on the receiving controller.
940#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
941pub struct RequestCaRotationPayload {
942    /// Human-readable reason for the rotation request.
943    pub reason: String,
944}
945
946/// Request all controller instances to rebuild the CRL immediately.
947///
948/// Published via NATS to the `uptrakit.events.controller` subject by any
949/// controller that revokes a certificate or by the `CrlRenewal` scheduled
950/// task.  Receiving controllers fire `revocation_notify.notify_one()`.
951#[non_exhaustive]
952#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
953pub struct RequestCrlRenewalPayload {}
954
955/// Signal that software states have changed for a tenant and need to be
956/// re-loaded and pushed to update-tracking services.
957///
958/// Published to the `controller` NATS subject by the external scheduler after
959/// a version-check run completes. The receiving controller loads the states
960/// from the database and pushes them to all connected update-tracking services.
961///
962/// This is a lightweight signal — it carries only the tenant ID, not the state
963/// data itself. This decouples the scheduler from the state-loading logic.
964#[non_exhaustive]
965#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
966pub struct SoftwareStatesChangedPayload {
967    pub tenant_id: uuid::Uuid,
968}
969
970impl SoftwareStatesChangedPayload {
971    pub fn new(tenant_id: uuid::Uuid) -> Self {
972        Self { tenant_id }
973    }
974}
975
976/// Cross-controller token revocation event.
977///
978/// Published to the `controller` NATS subject by the controller that wrote
979/// the revocation to the DB. Receiving controllers apply the revocation to
980/// their in-memory denylist only — they do **not** write to DB (the
981/// originating controller already did that).
982///
983/// A message may carry a JTI-level revocation, a user-level revocation, or
984/// both. Fields not relevant to the revocation type are `None`.
985#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
986pub struct TokenRevokedPayload {
987    /// JWT ID to deny (`exp` must also be set for JTI-level revocations).
988    #[serde(skip_serializing_if = "Option::is_none")]
989    pub jti: Option<String>,
990    /// Token expiry unix timestamp (seconds). Required when `jti` is set.
991    #[serde(skip_serializing_if = "Option::is_none")]
992    pub exp: Option<i64>,
993    /// User UUID for user-level revocations.
994    #[serde(skip_serializing_if = "Option::is_none")]
995    pub user_id: Option<Uuid>,
996    /// Deny tokens with `iat < iat_cutoff`. Required when `user_id` is set.
997    #[serde(skip_serializing_if = "Option::is_none")]
998    pub iat_cutoff: Option<i64>,
999    /// Remove the user entry after this unix timestamp.
1000    #[serde(skip_serializing_if = "Option::is_none")]
1001    pub purge_after: Option<i64>,
1002}
1003
1004/// Per-host metadata published to MQTT for MQTT-browser visibility and Home Assistant.
1005///
1006/// Included in [`SoftwareStatesPayload`]. All fields are sourced exclusively
1007/// from the shared DB — safe for multi-controller deployments.
1008///
1009/// Intentionally excludes `ip_address` (network topology risk) and `agent_online`
1010/// (must come from the event-driven [`HostConnectivityUpdatedPayload`]).
1011#[non_exhaustive]
1012#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1013pub struct HostStateMetadata {
1014    /// Host UUID.
1015    pub host_id: Uuid,
1016    /// Hostname as reported by the agent.
1017    pub hostname: String,
1018    /// User-defined display name.
1019    pub friendly_name: String,
1020    /// Operating system type (e.g. `"linux"`, `"macos"`). `null` when unknown.
1021    #[serde(default, skip_serializing_if = "Option::is_none")]
1022    pub os_type: Option<String>,
1023    /// Operating system version (e.g. `"Ubuntu 24.04 LTS"`). `null` when unknown.
1024    #[serde(default, skip_serializing_if = "Option::is_none")]
1025    pub os_version: Option<String>,
1026    /// CPU architecture (e.g. `"x86_64"`, `"aarch64"`). `null` when unknown.
1027    #[serde(default, skip_serializing_if = "Option::is_none")]
1028    pub architecture: Option<String>,
1029    /// Organisational tag names assigned to this host (e.g. `["production", "web-server"]`).
1030    #[serde(default)]
1031    pub tags: Vec<String>,
1032    /// Agent binary version string (e.g. `"0.2.1"`). `null` when never connected.
1033    ///
1034    /// Sourced from `services.client_version` for the newest approved, non-deactivated
1035    /// agent linked to this host.
1036    #[serde(default, skip_serializing_if = "Option::is_none")]
1037    pub agent_version: Option<String>,
1038    /// ISO 8601 timestamp of when the agent last sent a message.
1039    ///
1040    /// Sourced from `services.last_seen_at`. `null` when never seen.
1041    #[serde(default, skip_serializing_if = "Option::is_none")]
1042    pub agent_last_seen_at: Option<String>,
1043}
1044
1045impl HostStateMetadata {
1046    /// Creates a new `HostStateMetadata` with required fields.
1047    pub fn new(host_id: Uuid, hostname: String, friendly_name: String) -> Self {
1048        Self {
1049            host_id,
1050            hostname,
1051            friendly_name,
1052            os_type: None,
1053            os_version: None,
1054            architecture: None,
1055            tags: Vec::new(),
1056            agent_version: None,
1057            agent_last_seen_at: None,
1058        }
1059    }
1060}
1061
1062/// Connectivity status for a single host, used in [`HostConnectivityUpdatedPayload`].
1063#[non_exhaustive]
1064#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1065pub struct HostConnectivityUpdate {
1066    /// Host UUID.
1067    pub host_id: Uuid,
1068    /// Whether the agent is currently connected (`true` = online, `false` = offline).
1069    pub online: bool,
1070    /// Timestamp of last agent activity (ISO 8601). `null` when unavailable.
1071    #[serde(default, skip_serializing_if = "Option::is_none")]
1072    pub last_seen_at: Option<String>,
1073    /// Agent binary version. Present on connect; `null` on disconnect.
1074    #[serde(default, skip_serializing_if = "Option::is_none")]
1075    pub agent_version: Option<String>,
1076}
1077
1078impl HostConnectivityUpdate {
1079    /// Creates an online update.
1080    pub fn online(
1081        host_id: Uuid,
1082        last_seen_at: Option<String>,
1083        agent_version: Option<String>,
1084    ) -> Self {
1085        Self {
1086            host_id,
1087            online: true,
1088            last_seen_at,
1089            agent_version,
1090        }
1091    }
1092
1093    /// Creates an offline update.
1094    pub fn offline(host_id: Uuid, last_seen_at: Option<String>) -> Self {
1095        Self {
1096            host_id,
1097            online: false,
1098            last_seen_at,
1099            agent_version: None,
1100        }
1101    }
1102}
1103
1104/// Controller → MQTT service: agent connectivity changed for one or more hosts.
1105///
1106/// Published to NATS with `target_capability = "update_tracking"` so that the MQTT
1107/// service on whichever controller the agent is connected to broadcasts the
1108/// connectivity state to **all** MQTT services across the cluster. This is the
1109/// canonical source of truth for `{prefix}/hosts/{h}/connectivity/state`.
1110///
1111/// Multi-controller safety: published by the controller that owns the agent
1112/// WebSocket connection (the only one with authoritative live state). All other
1113/// controllers receive this via NATS and update their caches.
1114#[non_exhaustive]
1115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1116pub struct HostConnectivityUpdatedPayload {
1117    /// Tenant this update belongs to.
1118    pub tenant_id: Uuid,
1119    /// One entry per host whose connectivity changed.
1120    pub updates: Vec<HostConnectivityUpdate>,
1121}
1122
1123impl HostConnectivityUpdatedPayload {
1124    /// Creates a new payload.
1125    pub fn new(tenant_id: Uuid, updates: Vec<HostConnectivityUpdate>) -> Self {
1126        Self { tenant_id, updates }
1127    }
1128}
1129
1130/// Pagination metadata for a [`SoftwareStatesPayload`] message.
1131///
1132/// All payloads carry a `page` field. For single-page delivery use
1133/// `{ page_index: 0, total_pages: 1 }`. Multi-page delivery uses
1134/// `page_index` 0…N-1; the last page satisfies `page_index + 1 == total_pages`.
1135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1136pub struct SoftwareStatesPage {
1137    /// Zero-based index of this page.
1138    pub page_index: u32,
1139    /// Total number of pages in this delivery batch.
1140    pub total_pages: u32,
1141}
1142
1143impl SoftwareStatesPage {
1144    /// Creates a single-page marker (the only page in a single-page delivery).
1145    pub fn single() -> Self {
1146        Self {
1147            page_index: 0,
1148            total_pages: 1,
1149        }
1150    }
1151}
1152
1153/// Controller -> MQTT service: current software version state for a tenant.
1154///
1155/// Sent after tenant assignment and after any version check or update result.
1156/// Safe to write to the outbox (contains no credentials).
1157///
1158/// Large tenants use multi-page delivery. The `page` field indicates which
1159/// page this payload represents. Receivers must accumulate all pages before
1160/// applying the full state update (see `page_index + 1 == total_pages` for
1161/// the last-page signal).
1162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1163pub struct SoftwareStatesPayload {
1164    /// Tenant this state belongs to.
1165    pub tenant_id: Uuid,
1166    /// All active software items for the tenant with per-host version data.
1167    pub items: Vec<SoftwareStateItem>,
1168    /// Per-host aggregate summary of unpinned (unfeatured) software items.
1169    ///
1170    /// Each entry summarises all enabled, non-deactivated unfeatured items for
1171    /// one host. Only hosts with at least one such item are included.
1172    /// Defaults to an empty list on deserialization for backward compatibility
1173    /// with older MQTT services.
1174    #[serde(default, alias = "host_package_hosts")]
1175    pub host_summaries: Vec<HostPackageSummary>,
1176    /// Per-host metadata for all hosts referenced in `items` or `host_summaries`.
1177    ///
1178    /// Includes OS info, tags, and agent last-seen data. Sourced exclusively from DB.
1179    /// Defaults to an empty list for backward compatibility with older MQTT services.
1180    #[serde(default)]
1181    pub hosts: Vec<HostStateMetadata>,
1182    /// Pagination metadata indicating which page this payload represents.
1183    pub page: SoftwareStatesPage,
1184}
1185
1186/// A single software item entry in [`SoftwareStatesPayload`].
1187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1188pub struct SoftwareStateItem {
1189    /// Software item UUID.
1190    pub software_item_id: Uuid,
1191    /// Human-readable software item name.
1192    pub name: String,
1193    /// Optional HTTPS URL to an icon/logo image.
1194    #[serde(default, skip_serializing_if = "Option::is_none")]
1195    pub icon_url: Option<String>,
1196    /// Per-host version data for this software item.
1197    pub hosts: Vec<SoftwareStateHostEntry>,
1198}
1199
1200/// Per-host version data for a software item.
1201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1202pub struct SoftwareStateHostEntry {
1203    /// Host UUID.
1204    pub host_id: Uuid,
1205    /// Human-readable hostname.
1206    pub hostname: String,
1207    /// User-defined display name for the host.
1208    pub friendly_name: String,
1209    /// Currently installed version, if known.
1210    #[serde(default, skip_serializing_if = "Option::is_none")]
1211    pub installed_version: Option<String>,
1212    /// Latest available version, if known.
1213    #[serde(default, skip_serializing_if = "Option::is_none")]
1214    pub latest_version: Option<String>,
1215    /// Whether an update is available (`latest_version > installed_version`).
1216    pub update_available: bool,
1217    /// Whether an update is currently pending or in progress for this host-item pair.
1218    ///
1219    /// Set to `true` when an `update_history` record exists with status
1220    /// `Pending` or `InProgress`. Cleared to `false` once the update
1221    /// completes or fails. Defaults to `false` when absent (older controller).
1222    #[serde(default)]
1223    pub update_in_progress: bool,
1224    /// URL to the upstream release page (e.g. GitHub release), if available.
1225    #[serde(default, skip_serializing_if = "Option::is_none")]
1226    pub release_url: Option<String>,
1227    /// Release notes or changelog text, if available.
1228    #[serde(default, skip_serializing_if = "Option::is_none")]
1229    pub release_notes: Option<String>,
1230    /// Classification of the update (e.g. `"security"`, `"bugfix"`, `"feature"`, `"unknown"`).
1231    ///
1232    /// Sourced from `host_software_item.update_category`. Defaults to `"unknown"` when absent.
1233    #[serde(default, skip_serializing_if = "Option::is_none")]
1234    pub update_category: Option<String>,
1235    /// Date when the latest release was published (ISO 8601 date string, e.g. `"2025-01-15"`).
1236    ///
1237    /// Extracted from `latest_release_metadata.published_at`. `null` when metadata is absent.
1238    #[serde(default, skip_serializing_if = "Option::is_none")]
1239    pub release_date: Option<String>,
1240    /// Timestamp when the installed version was last detected (ISO 8601).
1241    ///
1242    /// Sourced from `host_software_item.installed_version_detected_at`. `null` when never checked.
1243    #[serde(default, skip_serializing_if = "Option::is_none")]
1244    pub last_checked_at: Option<String>,
1245}
1246
1247/// Service -> Controller: request to trigger a software update.
1248///
1249/// Sent when a Home Assistant user presses "Install" on an update entity.
1250/// The controller validates and dispatches the update to the appropriate agent.
1251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1252pub struct ServiceUpdateTriggerPayload {
1253    /// Tenant UUID (for validation).
1254    pub tenant_id: Uuid,
1255    /// Software item to update.
1256    pub software_item_id: Uuid,
1257    /// Host to update on.
1258    pub host_id: Uuid,
1259    /// Target version to install.
1260    pub to_version: String,
1261    /// Service instance UUID that initiated the trigger (used as actor_id).
1262    pub actor_service_id: Uuid,
1263}
1264
1265/// Per-host aggregate summary of unpinned (unfeatured) software items.
1266///
1267/// Included in [`SoftwareStatesPayload`] to surface overall update
1268/// status per host to Home Assistant via a single `update` entity per host.
1269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1270pub struct HostPackageSummary {
1271    /// Host UUID.
1272    pub host_id: Uuid,
1273    /// Human-readable hostname.
1274    pub hostname: String,
1275    /// User-defined display name for the host.
1276    #[serde(default)]
1277    pub friendly_name: String,
1278    /// Count of items where `installed_version != latest_version` (both known).
1279    pub pending_count: u32,
1280    /// Count of items where `update_category = "security"` AND versions differ.
1281    pub security_pending_count: u32,
1282    /// Total count of enabled, non-deactivated unfeatured items for this host.
1283    pub total_count: u32,
1284    /// Whether a batch update is currently pending or in progress for this host.
1285    pub update_in_progress: bool,
1286    /// Count of pending packages where `update_category = "bugfix"`.
1287    ///
1288    /// Defaults to `0` when absent (older controller that does not compute this field).
1289    #[serde(default)]
1290    pub bugfix_count: u32,
1291    /// Count of pending packages where `update_category = "feature"`.
1292    ///
1293    /// Defaults to `0` when absent (older controller that does not compute this field).
1294    #[serde(default)]
1295    pub feature_count: u32,
1296}
1297
1298/// Service → Controller: trigger a batch update of all outdated software items on a host.
1299///
1300/// Sent when a Home Assistant user presses "Install" on a host update
1301/// entity. The controller resolves the latest versions for all outdated items
1302/// at trigger time and dispatches a `ExecuteBatchUpdate` to the agent.
1303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1304pub struct ServiceHostBatchUpdateTriggerPayload {
1305    /// Tenant UUID (for validation).
1306    pub tenant_id: Uuid,
1307    /// Host whose items should be updated.
1308    pub host_id: Uuid,
1309    /// Service instance UUID that initiated the trigger (used as actor_id).
1310    pub actor_service_id: Uuid,
1311    /// When `true`, only items with `update_category = "security"` are updated.
1312    #[serde(default)]
1313    pub security_only: bool,
1314}
1315
1316/// Service -> Controller: forwarded semantic audit event.
1317///
1318/// The wire payload intentionally keeps semantic fields as strings so the
1319/// controller can re-validate them against its canonical audit contract before
1320/// persisting or exporting the event.
1321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1322pub struct AuditEventPayload {
1323    /// Semantic action identifier, such as `service.certificate.issue`.
1324    pub action_type: String,
1325    /// Tenant UUID as a string when the event is tenant-scoped.
1326    #[serde(default, skip_serializing_if = "Option::is_none")]
1327    pub tenant_id: Option<String>,
1328    /// Optional semantic target type.
1329    #[serde(default, skip_serializing_if = "Option::is_none")]
1330    pub target_type: Option<String>,
1331    /// Optional semantic target identifier.
1332    #[serde(default, skip_serializing_if = "Option::is_none")]
1333    pub target_id: Option<String>,
1334    /// Optional human-readable target display value.
1335    #[serde(default, skip_serializing_if = "Option::is_none")]
1336    pub target_display: Option<String>,
1337    /// Semantic outcome, such as `success` or `denied`.
1338    pub outcome: String,
1339    /// Optional JSON-encoded details payload.
1340    #[serde(default, skip_serializing_if = "Option::is_none")]
1341    pub details_json: Option<String>,
1342    /// Optional correlation identifier.
1343    #[serde(default, skip_serializing_if = "Option::is_none")]
1344    pub request_id: Option<String>,
1345    /// Optional correlation identifier linking events in a workflow chain.
1346    #[serde(default, skip_serializing_if = "Option::is_none")]
1347    pub correlation_id: Option<uuid::Uuid>,
1348}
1349
1350// =============================================================================
1351// Software Autodiscovery Payloads
1352// =============================================================================
1353
1354/// Controller -> Agent: Run software discovery on the given host.
1355///
1356/// The `plugins` list contains one entry per plugin that should be used.
1357/// When `plugin_config_id` is `None`, the assignment uses a default (empty)
1358/// config — the controller will auto-create a `PluginConfig` record once
1359/// results arrive.
1360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1361pub struct DiscoverSoftwarePayload {
1362    /// Machine ID of the host to discover software on.
1363    ///
1364    /// For the regular agent this is validated to match its own machine_id.
1365    /// For the SSH agent it identifies which remote host to connect to.
1366    pub host_machine_id: String,
1367    /// Per-plugin discovery assignments.
1368    pub plugins: Vec<DiscoveryPluginAssignment>,
1369}
1370
1371/// A single plugin assignment inside a [`DiscoverSoftwarePayload`].
1372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1373pub struct DiscoveryPluginAssignment {
1374    /// Pre-existing plugin config ID, or `None` for a default/auto run.
1375    #[serde(default, skip_serializing_if = "Option::is_none")]
1376    pub plugin_config_id: Option<Uuid>,
1377    /// Plugin type to use for discovery.
1378    pub plugin_type: PluginTypeId,
1379    /// Plugin-specific configuration (`{}` for default assignments).
1380    pub config: serde_json::Value,
1381}
1382
1383/// Agent -> Controller: Results of a software discovery run.
1384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1385pub struct DiscoveryResultsPayload {
1386    /// Machine ID of the host that was scanned (echoed from the assignment).
1387    pub host_machine_id: String,
1388    /// Per-plugin results.
1389    pub results: Vec<DiscoveryPluginResult>,
1390}
1391
1392/// Result for a single plugin inside a [`DiscoveryResultsPayload`].
1393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1394pub struct DiscoveryPluginResult {
1395    /// Echoed from [`DiscoveryPluginAssignment`] so the controller can route
1396    /// results to the correct `PluginConfig` row.
1397    #[serde(default, skip_serializing_if = "Option::is_none")]
1398    pub plugin_config_id: Option<Uuid>,
1399    /// Plugin type that produced these results.
1400    pub plugin_type: PluginTypeId,
1401    /// Discovered software items (empty on error).
1402    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1403    pub discoveries: Vec<DiscoveredSoftware>,
1404    /// Plugin-level error message, if discovery failed.
1405    #[serde(default, skip_serializing_if = "Option::is_none")]
1406    pub error: Option<String>,
1407}
1408
1409// =============================================================================
1410// Plugin config reporting
1411// =============================================================================
1412
1413/// Payload for `ServiceMessage::ReportPluginConfig`.
1414///
1415/// Sent by agents that detect infrastructure (e.g. PVE nodes) during bootstrap
1416/// and want the controller to create or retrieve a plugin configuration.
1417#[non_exhaustive]
1418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1419pub struct ReportPluginConfigPayload {
1420    /// Unique request identifier for correlating the response.
1421    pub request_id: String,
1422    /// Plugin type string (e.g. `"infrastructure_proxmox"`).
1423    pub plugin_type: String,
1424    /// Human-readable name for the config (e.g. `"pve.local"`).
1425    pub name: String,
1426    /// Plugin-specific configuration JSON.
1427    pub config: serde_json::Value,
1428}
1429
1430/// Payload for `ControllerMessage::ReportPluginConfigResponse`.
1431///
1432/// Returned to a service in response to `ReportPluginConfig`. Idempotent:
1433/// if a config with the same `(tenant_id, plugin_type, name)` already exists,
1434/// the existing ID is returned without creating a duplicate.
1435#[non_exhaustive]
1436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1437pub struct ReportPluginConfigResponsePayload {
1438    /// The request ID from the original `ReportPluginConfig` message.
1439    pub request_id: String,
1440    /// Whether the operation succeeded.
1441    pub success: bool,
1442    /// The plugin config ID (set on success).
1443    #[serde(default, skip_serializing_if = "Option::is_none")]
1444    pub plugin_config_id: Option<Uuid>,
1445    /// Error message (set on failure).
1446    #[serde(default, skip_serializing_if = "Option::is_none")]
1447    pub error: Option<String>,
1448}
1449
1450// =============================================================================
1451// Interactive Update Payloads
1452// =============================================================================
1453
1454/// Controller → Agent: forward stdin data or a signal to a running interactive update.
1455///
1456/// The `data` field contains raw bytes encoded as base64 to support binary
1457/// control sequences (e.g., `\x03` for Ctrl+C). When `signal` is set, the
1458/// agent delivers the signal to the process group instead of writing stdin.
1459#[non_exhaustive]
1460#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1461pub struct UpdateStdinDataPayload {
1462    /// The update history record this stdin data belongs to.
1463    pub update_history_id: Uuid,
1464    /// Raw bytes encoded as base64 (supports binary: Ctrl+C = \x03, etc.).
1465    pub data: String,
1466    /// When set, send this signal to the process group instead of writing stdin.
1467    /// Values: 2 = SIGINT, 15 = SIGTERM.
1468    #[serde(default, skip_serializing_if = "Option::is_none")]
1469    pub signal: Option<i32>,
1470}
1471
1472impl UpdateStdinDataPayload {
1473    /// Create a new stdin data payload.
1474    pub fn new(update_history_id: Uuid, data: String) -> Self {
1475        Self {
1476            update_history_id,
1477            data,
1478            signal: None,
1479        }
1480    }
1481
1482    /// Create a new signal payload.
1483    pub fn with_signal(update_history_id: Uuid, signal: i32) -> Self {
1484        Self {
1485            update_history_id,
1486            data: String::new(),
1487            signal: Some(signal),
1488        }
1489    }
1490}
1491
1492/// Agent → Controller: the update process appears to be waiting for stdin input.
1493///
1494/// Sent when the agent detects sustained silence from the process (no output for
1495/// ~10 seconds while still running). The controller broadcasts this to interactive
1496/// session subscribers and may trigger notifications.
1497#[non_exhaustive]
1498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1499pub struct StdinAttentionPayload {
1500    /// The update history record that needs attention.
1501    pub update_history_id: Uuid,
1502    /// Optional hint about what the process might be waiting for.
1503    #[serde(default, skip_serializing_if = "Option::is_none")]
1504    pub hint: Option<String>,
1505}
1506
1507impl StdinAttentionPayload {
1508    /// Create a new attention payload.
1509    pub fn new(update_history_id: Uuid) -> Self {
1510        Self {
1511            update_history_id,
1512            hint: None,
1513        }
1514    }
1515
1516    /// Create a new attention payload with a hint.
1517    pub fn with_hint(update_history_id: Uuid, hint: String) -> Self {
1518        Self {
1519            update_history_id,
1520            hint: Some(hint),
1521        }
1522    }
1523}
1524
1525// --- Cross-controller admin event broadcast ---
1526
1527/// Cross-controller admin event broadcast payload.
1528///
1529/// Published via NATS to the `controller` subject by any controller instance
1530/// when it emits an [`AdminEvent`](crate::admin_events::AdminEvent)
1531/// to local SSE subscribers. Receiving controller instances decode the payload
1532/// and re-broadcast to their own local SSE subscribers without re-publishing
1533/// to NATS (to avoid infinite loops).
1534///
1535/// `tenant_id = None` means the event targets all tenants (system-wide).
1536///
1537/// **Safe to publish via NATS** — contains no credential material.
1538#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1539pub struct BroadcastAdminEventPayload {
1540    /// Target tenant, or `None` for system-wide events.
1541    pub tenant_id: Option<Uuid>,
1542    /// JSON-serialised `AdminEvent`.
1543    pub event_json: String,
1544}
1545
1546// =============================================================================
1547// Workload Claim Protocol Payloads
1548// =============================================================================
1549
1550/// Service → Controller: request exclusive ownership of config keys.
1551///
1552/// Each key in `claims` is a config key (e.g. `"clients.{uuid}"`) and the
1553/// value is the `tenant_id` that config belongs to. The controller grants
1554/// unclaimed keys and rejects keys already claimed by another service.
1555///
1556/// Uses **full replacement semantics**: each `WorkloadClaim` sends the
1557/// complete desired config key set. The controller diffs against current
1558/// grants to determine what to claim/release.
1559#[non_exhaustive]
1560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1561pub struct WorkloadClaimPayload {
1562    /// Map of `config_key → tenant_id` representing the full desired set.
1563    pub claims: BTreeMap<String, Uuid>,
1564}
1565
1566impl WorkloadClaimPayload {
1567    /// Create a new `WorkloadClaimPayload`.
1568    pub fn new(claims: BTreeMap<String, Uuid>) -> Self {
1569        Self { claims }
1570    }
1571}
1572
1573/// Controller → Service: grant/reject response for a workload claim request.
1574///
1575/// Sent in response to `WorkloadClaim`, or unsolicited when the controller
1576/// proactively re-grants previously rejected keys that became available
1577/// (e.g. after another service disconnected), or when revoking keys due
1578/// to cross-controller conflict resolution.
1579#[non_exhaustive]
1580#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1581pub struct WorkloadClaimResultPayload {
1582    /// Config keys that were granted (exclusive ownership).
1583    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1584    pub granted: BTreeSet<String>,
1585    /// Config keys that were rejected (already claimed by another service).
1586    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1587    pub rejected: BTreeSet<String>,
1588}
1589
1590impl WorkloadClaimResultPayload {
1591    /// Create a new `WorkloadClaimResultPayload`.
1592    pub fn new(granted: BTreeSet<String>, rejected: BTreeSet<String>) -> Self {
1593        Self { granted, rejected }
1594    }
1595}
1596
1597/// Service → Controller: voluntarily release config keys.
1598///
1599/// Sent when a service no longer wants to serve certain configs (e.g. after
1600/// a config deletion). The controller releases the keys and makes them
1601/// available for other services.
1602#[non_exhaustive]
1603#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1604pub struct WorkloadReleasePayload {
1605    /// Config keys to release.
1606    pub keys: BTreeSet<String>,
1607}
1608
1609impl WorkloadReleasePayload {
1610    /// Create a new `WorkloadReleasePayload`.
1611    pub fn new(keys: BTreeSet<String>) -> Self {
1612        Self { keys }
1613    }
1614}
1615
1616/// Controller → NATS: announce claim state changes for cross-controller sync.
1617///
1618/// Published to the `controller` NATS subject after granting or releasing
1619/// claims. Other controllers update their global claim registry from this.
1620///
1621/// **Safe to publish via NATS** — contains no credential material.
1622#[non_exhaustive]
1623#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1624pub struct WorkloadClaimAnnouncementPayload {
1625    /// The service that owns these claims.
1626    pub service_id: Uuid,
1627    /// The controller that granted these claims.
1628    pub controller_id: Uuid,
1629    /// Newly claimed keys: `config_key → tenant_id`.
1630    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1631    pub claimed: BTreeMap<String, Uuid>,
1632    /// Keys that were released.
1633    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1634    pub released: BTreeSet<String>,
1635    /// ISO 8601 timestamp when the claims were granted (for conflict resolution).
1636    pub claimed_at: String,
1637}
1638
1639impl WorkloadClaimAnnouncementPayload {
1640    /// Create a new `WorkloadClaimAnnouncementPayload`.
1641    pub fn new(
1642        service_id: Uuid,
1643        controller_id: Uuid,
1644        claimed: BTreeMap<String, Uuid>,
1645        released: BTreeSet<String>,
1646        claimed_at: String,
1647    ) -> Self {
1648        Self {
1649            service_id,
1650            controller_id,
1651            claimed,
1652            released,
1653            claimed_at,
1654        }
1655    }
1656}
1657
1658/// Controller → NATS: request full claim state from all active controllers.
1659///
1660/// Published on controller startup to the `controller` NATS subject.
1661/// Each active controller responds with `WorkloadClaimSyncResponse`.
1662///
1663/// **NATS-only** (controller-to-controller), not service-facing.
1664#[non_exhaustive]
1665#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1666pub struct WorkloadClaimSyncRequestPayload {
1667    /// The requesting controller's ID.
1668    pub controller_id: Uuid,
1669}
1670
1671impl WorkloadClaimSyncRequestPayload {
1672    /// Create a new `WorkloadClaimSyncRequestPayload`.
1673    pub fn new(controller_id: Uuid) -> Self {
1674        Self { controller_id }
1675    }
1676}
1677
1678/// Controller → NATS: respond with full local claim state.
1679///
1680/// Sent in response to `WorkloadClaimSyncRequest`. Contains the responding
1681/// controller's complete local claim map for merging into the requester's
1682/// global registry.
1683///
1684/// **NATS-only** (controller-to-controller), not service-facing.
1685#[non_exhaustive]
1686#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1687pub struct WorkloadClaimSyncResponsePayload {
1688    /// The responding controller's ID.
1689    pub controller_id: Uuid,
1690    /// Full local claim state: `config_key → (service_id, tenant_id)`.
1691    pub claims: BTreeMap<String, WorkloadClaimSyncEntry>,
1692}
1693
1694impl WorkloadClaimSyncResponsePayload {
1695    /// Create a new `WorkloadClaimSyncResponsePayload`.
1696    pub fn new(controller_id: Uuid, claims: BTreeMap<String, WorkloadClaimSyncEntry>) -> Self {
1697        Self {
1698            controller_id,
1699            claims,
1700        }
1701    }
1702}
1703
1704/// A single entry in a `WorkloadClaimSyncResponse`.
1705#[non_exhaustive]
1706#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1707pub struct WorkloadClaimSyncEntry {
1708    /// The service that owns this claim.
1709    pub service_id: Uuid,
1710    /// The tenant this config key belongs to.
1711    pub tenant_id: Uuid,
1712    /// ISO 8601 timestamp when the claim was granted.
1713    pub claimed_at: String,
1714}
1715
1716impl WorkloadClaimSyncEntry {
1717    /// Create a new `WorkloadClaimSyncEntry`.
1718    pub fn new(service_id: Uuid, tenant_id: Uuid, claimed_at: String) -> Self {
1719        Self {
1720            service_id,
1721            tenant_id,
1722            claimed_at,
1723        }
1724    }
1725}
1726
1727// ── Config test payloads ─────────────────────────────────────────────────────
1728
1729// Must be `pub use`, not bare `use` — wire's lib.rs does `pub use payloads::*`,
1730// which only re-exports *pub* items. A private import here would silently drop
1731// ConfigTestKind from the wire public API and break all 21 non-plugin dependents.
1732pub use uptrakit_shared_types::ConfigTestKind;
1733
1734/// Payload for a plugin configuration test request (controller -> agent).
1735#[non_exhaustive]
1736#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1737pub struct TestPluginConfigPayload {
1738    /// Unique request ID for correlation (UUID v7).
1739    pub request_id: String,
1740    /// Target host machine ID on the agent.
1741    pub host_machine_id: String,
1742    /// What to test.
1743    pub test_kind: ConfigTestKind,
1744    /// The plugin type to test.
1745    pub plugin_type: String,
1746    /// The plugin configuration JSON to test.
1747    pub config: serde_json::Value,
1748    /// Package identifier for testing (required for version detection).
1749    #[serde(default, skip_serializing_if = "Option::is_none")]
1750    pub package_identifier: Option<String>,
1751}
1752
1753impl TestPluginConfigPayload {
1754    /// Creates a new test plugin config payload.
1755    pub fn new(
1756        request_id: String,
1757        host_machine_id: String,
1758        test_kind: ConfigTestKind,
1759        plugin_type: String,
1760        config: serde_json::Value,
1761    ) -> Self {
1762        Self {
1763            request_id,
1764            host_machine_id,
1765            test_kind,
1766            plugin_type,
1767            config,
1768            package_identifier: None,
1769        }
1770    }
1771}
1772
1773/// Payload for a plugin configuration test result (agent -> controller).
1774#[non_exhaustive]
1775#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1776pub struct TestPluginConfigResultPayload {
1777    /// Correlation ID matching the original request.
1778    pub request_id: String,
1779    /// Whether the test passed.
1780    pub success: bool,
1781    /// Command output or connectivity response.
1782    #[serde(default, skip_serializing_if = "Option::is_none")]
1783    pub output: Option<String>,
1784    /// Error message if the test failed.
1785    #[serde(default, skip_serializing_if = "Option::is_none")]
1786    pub error: Option<String>,
1787    /// Detected version (for version detection tests).
1788    #[serde(default, skip_serializing_if = "Option::is_none")]
1789    pub detected_version: Option<String>,
1790    /// Test duration in milliseconds.
1791    pub duration_ms: u64,
1792}
1793
1794impl TestPluginConfigResultPayload {
1795    /// Creates a new test result payload.
1796    pub fn new(request_id: String, success: bool, duration_ms: u64) -> Self {
1797        Self {
1798            request_id,
1799            success,
1800            output: None,
1801            error: None,
1802            detected_version: None,
1803            duration_ms,
1804        }
1805    }
1806}
1807
1808#[cfg(test)]
1809mod resumable_tests {
1810    use super::*;
1811
1812    #[test]
1813    fn test_update_result_payload_resumable_defaults_none() {
1814        let json = r#"{"update_history_id":"00000000-0000-0000-0000-000000000001","status":"completed","output":""}"#;
1815        let p: UpdateResultPayload = serde_json::from_str(json).unwrap();
1816        assert_eq!(p.resumable, None);
1817    }
1818
1819    #[test]
1820    fn test_update_result_payload_resumable_true_round_trips() {
1821        let p = UpdateResultPayload {
1822            update_history_id: uuid::Uuid::nil(),
1823            status: crate::UpdateFinalStatus::Completed,
1824            from_version: None,
1825            to_version: None,
1826            output: String::new(),
1827            error: None,
1828            resumable: Some(true),
1829        };
1830        let json = serde_json::to_string(&p).unwrap();
1831        assert!(json.contains("\"resumable\":true"));
1832        let back: UpdateResultPayload = serde_json::from_str(&json).unwrap();
1833        assert_eq!(back.resumable, Some(true));
1834    }
1835
1836    #[test]
1837    fn test_version_check_result_not_ready_defaults_none() {
1838        let json = r#"{"software_item_id":"00000000-0000-0000-0000-000000000001","update_category":"none"}"#;
1839        let r: VersionCheckResult = serde_json::from_str(json).unwrap();
1840        assert_eq!(r.not_ready, None);
1841    }
1842
1843    #[test]
1844    fn test_version_check_result_not_ready_true_round_trips() {
1845        let r = VersionCheckResult {
1846            software_item_id: uuid::Uuid::nil(),
1847            installed_version: None,
1848            latest_version: None,
1849            error: None,
1850            update_category: crate::UpdateCategory::default(),
1851            host_software_item_id: None,
1852            installed_display_version: None,
1853            not_ready: Some(true),
1854        };
1855        let json = serde_json::to_string(&r).unwrap();
1856        assert!(json.contains("\"not_ready\":true"));
1857        let back: VersionCheckResult = serde_json::from_str(&json).unwrap();
1858        assert_eq!(back.not_ready, Some(true));
1859    }
1860}