Skip to main content

sysknife_types/
lib.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::convert::TryFrom;
4
5use sysknife_proto::sysknife::v1 as proto;
6
7// ---------------------------------------------------------------------------
8// DistroHint — planner-facing snapshot of the running distro
9// ---------------------------------------------------------------------------
10
11/// Planner-facing distro snapshot injected into the system prompt.
12///
13/// This is a deliberately lightweight type: it captures only what the planner
14/// needs to pick the right action family (`family`) and to produce accurate
15/// human-readable output (`version`).  Heavy detection logic and the full
16/// `DistroId` enum stay in `sysknife-core`; the CLI converts `DistroId` →
17/// `DistroHint` at startup so the brain never depends on `sysknife-core`.
18///
19/// # Design rationale
20///
21/// Moving `DistroId` to `sysknife-types` would add proto-bridge boilerplate
22/// and force every crate that imports types to compile the detection logic.
23/// A thin snapshot type is smaller-diff and still gives the planner everything
24/// it needs.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct DistroHint {
27    /// Broad distro family: `"fedora"`, `"debian"`, or `"other"`.
28    ///
29    /// Use `DISTRO_FAMILY_FEDORA`, `DISTRO_FAMILY_DEBIAN`, and
30    /// `DISTRO_FAMILY_OTHER` for comparison to avoid magic strings.
31    pub family: &'static str,
32    /// Human-readable version string, e.g. `"Fedora 41"`, `"Ubuntu 24.04"`.
33    /// `None` when the version could not be determined.
34    pub version: Option<String>,
35}
36
37/// Family label for Fedora-family distros (Fedora, FedoraSilverblue, etc.).
38pub const DISTRO_FAMILY_FEDORA: &str = "fedora";
39
40/// Family label for Debian-family distros (Ubuntu, Debian, etc.).
41pub const DISTRO_FAMILY_DEBIAN: &str = "debian";
42
43/// Family label for distros that do not fit into a known family.
44pub const DISTRO_FAMILY_OTHER: &str = "other";
45
46// ---------------------------------------------------------------------------
47// ActionName — validated action catalogue primitive
48// ---------------------------------------------------------------------------
49
50/// Canonical names of every action in the SysKnife catalogue.
51///
52/// This is the single source of truth for "is this string a valid action
53/// name?" — kept in `sysknife-types` (not `sysknife-brain`) so every crate
54/// that validates an action name (the daemon's `policy::min_role_for_action`,
55/// the CLI's approval flow, the proto bridge) sees the same list without
56/// depending on the brain.
57///
58/// Note: [`RequestEnvelope::action_name`] itself is a plain `String` — it is
59/// **not** validated against this list at deserialization time; that would
60/// require a custom `Deserialize` impl this type does not have. Validation
61/// happens downstream, via [`ActionName::parse`] against the value once it
62/// has been pulled out of the envelope (the daemon does this before acting
63/// on a request).
64///
65/// **Cross-module invariant:** `sysknife_brain::propose_plan::KNOWN_ACTIONS`
66/// (which pairs each name with an LLM-facing description) MUST list every
67/// name that appears here, in either order.  The brain has a unit test
68/// (`every_known_action_name_is_in_types`) that pins this — adding an
69/// action requires a coordinated update to both lists.
70pub const KNOWN_ACTION_NAMES: &[&str] = &[
71    "GetSystemState",
72    "CollectDiagnostics",
73    "GetDeploymentHistory",
74    "ListDeployments",
75    "UpdateSystem",
76    "CleanupDeployments",
77    "RebootSystem",
78    "RollbackDeployment",
79    "GetKernelArguments",
80    "PinDeployment",
81    "UnpinDeployment",
82    "RebaseSystem",
83    "SetKernelArguments",
84    "InstallFlatpak",
85    "RemoveFlatpak",
86    "UpdateFlatpak",
87    "SearchFlatpakApps",
88    "ListFlatpakRemotes",
89    "ListInstalledFlatpaks",
90    "AddFlatpakRemote",
91    "RemoveFlatpakRemote",
92    "GetFlatpakAppInfo",
93    "ListToolboxes",
94    "CreateToolbox",
95    "RemoveToolbox",
96    "GetLayeredPackages",
97    "ResetLayeredPackageOverride",
98    "GetPendingUpdates",
99    "InstallPackages",
100    "RemovePackages",
101    "AddLayeredPackage",
102    "RemoveLayeredPackage",
103    "ReplaceLayeredPackage",
104    "RemoveBasePackage",
105    "ListServices",
106    "ListTimers",
107    "ReloadDaemon",
108    "CreateScheduledJob",
109    "StartService",
110    "StopService",
111    "RestartService",
112    "ReloadService",
113    "SetServiceEnabled",
114    "MaskService",
115    "UnmaskService",
116    "GetServiceLogs",
117    "GetServiceStatus",
118    "GetServiceResourceLimits",
119    "SetServiceResourceLimits",
120    "GetFirewallState",
121    "GetNetworkStatus",
122    "GetListeningPorts",
123    "ConfigureWifi",
124    "SetDnsServers",
125    "ConfigureFirewall",
126    "GetDiskUsage",
127    "ListProcesses",
128    "SignalProcess",
129    "GetMemoryInfo",
130    // Observability / journald (cross-distro)
131    "GetJournalLog",
132    "VacuumJournal",
133    // Storage / LVM (cross-distro)
134    "GetLvmReport",
135    "ExtendLogicalVolume",
136    "CreateLogicalVolume",
137    "CreateLvSnapshot",
138    // Kernel / sysctl (cross-distro)
139    "GetSysctl",
140    "SetSysctl",
141    // Filesystem mounts / swap (cross-distro)
142    "GetMounts",
143    "AddMount",
144    "RemoveMount",
145    "AddSwap",
146    "RemoveSwap",
147    // Scoped sudoers.d (cross-distro)
148    "GetSudoGrants",
149    "GrantSudoAccess",
150    "RevokeSudoAccess",
151    // Log management (cross-distro)
152    "GetLogrotateStatus",
153    "ConfigureLogRotation",
154    "RemoveLogRotation",
155    "ConfigureRemoteSyslog",
156    "RemoveRemoteSyslog",
157    // PAM password policy (cross-distro)
158    "GetPasswordAging",
159    "SetPasswordAging",
160    "SetPasswordPolicy",
161    "SetAccountLockout",
162    // auditd file-watch rules (cross-distro)
163    "GetAuditRules",
164    "AddAuditRule",
165    "RemoveAuditRule",
166    // certbot / ACME (cross-distro)
167    "GetCertificates",
168    "ObtainCertificate",
169    "RenewCertificates",
170    "GetDateTime",
171    "SetHostname",
172    "SetTimezone",
173    "SetLocale",
174    "SetNtp",
175    "ListPackageRepositories",
176    "AddPackageRepository",
177    "RemovePackageRepository",
178    "EnablePackageRepository",
179    "DisablePackageRepository",
180    "ListContainers",
181    "CreateContainer",
182    "StartContainer",
183    "StopContainer",
184    "RemoveContainer",
185    "GetContainerInfo",
186    "ListUsers",
187    "ListGroups",
188    "CreateUser",
189    "DeleteUser",
190    "AddUserToGroup",
191    "RemoveUserFromGroup",
192    "CreateGroup",
193    "DeleteGroup",
194    "LockUserAccount",
195    "UnlockUserAccount",
196    "GetAuthorizedKeys",
197    "AddAuthorizedKey",
198    "RemoveAuthorizedKey",
199    "SetSshdOption",
200    "ListJobHistory",
201    // Ubuntu / apt
202    "AptUpdate",
203    "AptUpgrade",
204    "AptInstall",
205    "AptRemove",
206    "AptPurge",
207    "AptAutoremove",
208    "AptHold",
209    "AptUnhold",
210    "AptSearch",
211    "AptListInstalled",
212    "AptShow",
213    "AptListUpgradable",
214    "AptHistoryList",
215    "ConfigureUnattendedUpgrades",
216    "GetAptPins",
217    "SetAptPin",
218    "RemoveAptPin",
219    // Ubuntu / ppa
220    "AddPpa",
221    "RemovePpa",
222    // Ubuntu / snap
223    "SnapInstall",
224    "SnapRemove",
225    "SnapRefresh",
226    "SnapHold",
227    "SnapUnhold",
228    "SnapList",
229    "SnapInfo",
230    "SnapRevert",
231    "SnapClassicInstall",
232    // Ubuntu / ufw
233    "UfwEnable",
234    "UfwDisable",
235    "UfwAllow",
236    "UfwDeny",
237    "UfwReset",
238    "UfwStatus",
239    // Ubuntu / distrobox
240    "DistroboxList",
241    "DistroboxCreate",
242    "DistroboxRemove",
243    // Ubuntu / netplan
244    "NetplanGetConfig",
245    "NetplanApply",
246    "NetplanSet",
247    "NetplanGenerate",
248    // Ubuntu / grub
249    "GrubGetKargs",
250    "GrubSetKargs",
251    // Ubuntu / reboot
252    "CheckPendingReboot",
253    // Cross-distro / resolvectl (systemd-resolved)
254    "ResolvectlStatus",
255    "ResolvectlSetDns",
256    // Ubuntu / apparmor
257    "AppArmorStatus",
258    "AppArmorEnforce",
259    "AppArmorComplain",
260    // Ubuntu / cloud-init
261    "CloudInitStatus",
262    // Ubuntu / flatpak (Ubuntu-specific routing)
263    "UbuntuInstallFlatpak",
264    "UbuntuRemoveFlatpak",
265    "UbuntuUpdateFlatpak",
266    "UbuntuListFlatpaks",
267    // Ubuntu / fail2ban
268    "Fail2banStatus",
269    "Fail2banBanIp",
270    "Fail2banUnbanIp",
271    "ConfigureFail2banJail",
272    // Ubuntu / release upgrade (Tier 3)
273    "UbuntuReleaseUpgrade",
274    // Ubuntu / Ubuntu Pro (Tier 3)
275    "ProStatus",
276    "ProAttach",
277    "ProDetach",
278    "EnableProService",
279    "DisableProService",
280    // Ubuntu / Livepatch (Tier 3)
281    "LivepatchStatus",
282    // Ubuntu / Multipass (Tier 3)
283    "MultipassList",
284    // Ubuntu / ufw Tier 3
285    "UfwDeleteRule",
286    "UfwLimit",
287];
288
289/// A validated action name from the approved SysKnife catalogue.
290///
291/// Constructed via [`ActionName::parse`], which rejects any string not in
292/// [`KNOWN_ACTION_NAMES`].  Holding an `ActionName` is a compile-time
293/// guarantee that the contained string is in the catalogue — downstream
294/// code does not need to re-validate.
295#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
296#[serde(transparent)]
297pub struct ActionName(String);
298
299impl ActionName {
300    /// Parse a string into a validated `ActionName`.
301    pub fn parse(name: impl Into<String>) -> Result<Self, UnknownActionName> {
302        let name = name.into();
303        if KNOWN_ACTION_NAMES.contains(&name.as_str()) {
304            Ok(Self(name))
305        } else {
306            Err(UnknownActionName(name))
307        }
308    }
309
310    /// Return the inner string slice.
311    pub fn as_str(&self) -> &str {
312        &self.0
313    }
314}
315
316impl std::fmt::Display for ActionName {
317    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318        f.write_str(&self.0)
319    }
320}
321
322impl AsRef<str> for ActionName {
323    fn as_ref(&self) -> &str {
324        &self.0
325    }
326}
327
328/// Returned by [`ActionName::parse`] when the string is not a known action.
329#[derive(Clone, Debug, PartialEq, Eq)]
330pub struct UnknownActionName(pub String);
331
332impl std::fmt::Display for UnknownActionName {
333    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334        write!(f, "unknown action name: '{}'", self.0)
335    }
336}
337
338impl std::error::Error for UnknownActionName {}
339
340// ---------------------------------------------------------------------------
341// Request hash newtype
342// ---------------------------------------------------------------------------
343
344/// Hex-encoded SHA-256 hash of a [`RequestEnvelope`].
345///
346/// `serde(transparent)` keeps the wire format identical to a bare string so
347/// existing JSON IPC frames deserialize unchanged.
348#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
349#[serde(transparent)]
350pub struct RequestHash(String);
351
352impl RequestHash {
353    pub fn new(s: impl Into<String>) -> Self {
354        Self(s.into())
355    }
356    pub fn as_str(&self) -> &str {
357        &self.0
358    }
359    pub fn into_inner(self) -> String {
360        self.0
361    }
362}
363
364impl std::fmt::Display for RequestHash {
365    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366        f.write_str(&self.0)
367    }
368}
369
370impl AsRef<str> for RequestHash {
371    fn as_ref(&self) -> &str {
372        &self.0
373    }
374}
375
376impl From<String> for RequestHash {
377    fn from(s: String) -> Self {
378        Self(s)
379    }
380}
381
382#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
383#[serde(rename_all = "snake_case")]
384pub enum CallerRole {
385    Observer,
386    Dev,
387    Admin,
388    Boot,
389}
390
391#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(rename_all = "snake_case")]
393pub enum RiskLevel {
394    Low,
395    Medium,
396    High,
397}
398
399#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(rename_all = "snake_case")]
401pub enum JobState {
402    Queued,
403    Running,
404    Succeeded,
405    Failed,
406    Canceled,
407    RolledBack,
408    NeedsReboot,
409}
410
411/// Structured failure categories for the IPC error protocol.
412///
413/// Currently used only in the proto bridge layer (sysknife-types <-> sysknife-proto
414/// conversions) and their tests. The daemon dispatcher uses string category
415/// names on the wire today; these variants will replace those strings when the
416/// daemon adopts the proto layer end-to-end.
417#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
418#[serde(rename_all = "snake_case")]
419pub enum FailureCategory {
420    ValidationFailure,
421    AuthorizationFailure,
422    PolicyDenied,
423    StaleApproval,
424    ExecutionFailure,
425    TransientInfrastructureFailure,
426    Cancellation,
427    StuckExecution,
428    RebootRequired,
429    RollbackFailure,
430}
431
432#[derive(Debug, thiserror::Error)]
433pub enum BridgeError {
434    #[error("invalid json payload for {0}")]
435    InvalidJson(&'static str, #[source] serde_json::Error),
436
437    #[error("invalid enum value {value} for {field}")]
438    InvalidEnum { field: &'static str, value: i32 },
439}
440
441#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
442pub struct RequestEnvelope {
443    pub action_name: String,
444    pub request_id: String,
445    pub params: Value,
446    pub caller_role: CallerRole,
447    pub request_hash: RequestHash,
448}
449
450#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
451pub struct PreviewEnvelope {
452    pub summary: String,
453    pub risk_level: RiskLevel,
454    pub current_state: Value,
455    pub proposed_change: Value,
456    pub expected_side_effects: Vec<String>,
457    pub reboot_required: bool,
458    pub rollback_available: bool,
459    pub warnings: Vec<String>,
460    pub request_hash: RequestHash,
461}
462
463#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
464pub struct ResultEnvelope {
465    pub status: JobState,
466    pub summary: String,
467    pub warnings: Vec<String>,
468    pub job_id: Option<String>,
469    pub needs_reboot: bool,
470    pub rollback_ref: Option<String>,
471    pub transaction_id: String,
472}
473
474#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
475pub struct TransactionRecord {
476    pub transaction_id: String,
477    pub request_id: String,
478    pub request_hash: String,
479    pub action_name: String,
480    pub risk_level: RiskLevel,
481    pub status: JobState,
482    pub approval_id: Option<String>,
483    pub summary: String,
484    pub warnings: Vec<String>,
485}
486
487fn caller_role_code(value: CallerRole) -> i32 {
488    match value {
489        CallerRole::Observer => 1,
490        CallerRole::Dev => 2,
491        CallerRole::Admin => 3,
492        CallerRole::Boot => 4,
493    }
494}
495
496fn risk_level_code(value: RiskLevel) -> i32 {
497    match value {
498        RiskLevel::Low => 1,
499        RiskLevel::Medium => 2,
500        RiskLevel::High => 3,
501    }
502}
503
504fn job_state_code(value: JobState) -> i32 {
505    match value {
506        JobState::Queued => 1,
507        JobState::Running => 2,
508        JobState::Succeeded => 3,
509        JobState::Failed => 4,
510        JobState::Canceled => 5,
511        JobState::RolledBack => 6,
512        JobState::NeedsReboot => 7,
513    }
514}
515
516fn failure_category_code(value: FailureCategory) -> i32 {
517    match value {
518        FailureCategory::ValidationFailure => 1,
519        FailureCategory::AuthorizationFailure => 2,
520        FailureCategory::PolicyDenied => 3,
521        FailureCategory::StaleApproval => 4,
522        FailureCategory::ExecutionFailure => 5,
523        FailureCategory::TransientInfrastructureFailure => 6,
524        FailureCategory::Cancellation => 7,
525        FailureCategory::StuckExecution => 8,
526        FailureCategory::RebootRequired => 9,
527        FailureCategory::RollbackFailure => 10,
528    }
529}
530
531impl From<CallerRole> for proto::CallerRole {
532    fn from(value: CallerRole) -> Self {
533        proto::CallerRole::try_from(caller_role_code(value)).expect("valid caller role")
534    }
535}
536
537impl TryFrom<proto::CallerRole> for CallerRole {
538    type Error = BridgeError;
539
540    fn try_from(value: proto::CallerRole) -> Result<Self, Self::Error> {
541        match i32::from(value) {
542            1 => Ok(CallerRole::Observer),
543            2 => Ok(CallerRole::Dev),
544            3 => Ok(CallerRole::Admin),
545            4 => Ok(CallerRole::Boot),
546            code => Err(BridgeError::InvalidEnum {
547                field: "caller_role",
548                value: code,
549            }),
550        }
551    }
552}
553
554impl From<RiskLevel> for proto::RiskLevel {
555    fn from(value: RiskLevel) -> Self {
556        proto::RiskLevel::try_from(risk_level_code(value)).expect("valid risk level")
557    }
558}
559
560impl TryFrom<proto::RiskLevel> for RiskLevel {
561    type Error = BridgeError;
562
563    fn try_from(value: proto::RiskLevel) -> Result<Self, Self::Error> {
564        match i32::from(value) {
565            1 => Ok(RiskLevel::Low),
566            2 => Ok(RiskLevel::Medium),
567            3 => Ok(RiskLevel::High),
568            code => Err(BridgeError::InvalidEnum {
569                field: "risk_level",
570                value: code,
571            }),
572        }
573    }
574}
575
576impl From<JobState> for proto::JobState {
577    fn from(value: JobState) -> Self {
578        proto::JobState::try_from(job_state_code(value)).expect("valid job state")
579    }
580}
581
582impl TryFrom<proto::JobState> for JobState {
583    type Error = BridgeError;
584
585    fn try_from(value: proto::JobState) -> Result<Self, Self::Error> {
586        match i32::from(value) {
587            1 => Ok(JobState::Queued),
588            2 => Ok(JobState::Running),
589            3 => Ok(JobState::Succeeded),
590            4 => Ok(JobState::Failed),
591            5 => Ok(JobState::Canceled),
592            6 => Ok(JobState::RolledBack),
593            7 => Ok(JobState::NeedsReboot),
594            code => Err(BridgeError::InvalidEnum {
595                field: "job_state",
596                value: code,
597            }),
598        }
599    }
600}
601
602impl From<FailureCategory> for proto::FailureCategory {
603    fn from(value: FailureCategory) -> Self {
604        proto::FailureCategory::try_from(failure_category_code(value))
605            .expect("valid failure category")
606    }
607}
608
609impl TryFrom<proto::FailureCategory> for FailureCategory {
610    type Error = BridgeError;
611
612    fn try_from(value: proto::FailureCategory) -> Result<Self, Self::Error> {
613        match i32::from(value) {
614            1 => Ok(FailureCategory::ValidationFailure),
615            2 => Ok(FailureCategory::AuthorizationFailure),
616            3 => Ok(FailureCategory::PolicyDenied),
617            4 => Ok(FailureCategory::StaleApproval),
618            5 => Ok(FailureCategory::ExecutionFailure),
619            6 => Ok(FailureCategory::TransientInfrastructureFailure),
620            7 => Ok(FailureCategory::Cancellation),
621            8 => Ok(FailureCategory::StuckExecution),
622            9 => Ok(FailureCategory::RebootRequired),
623            10 => Ok(FailureCategory::RollbackFailure),
624            code => Err(BridgeError::InvalidEnum {
625                field: "failure_category",
626                value: code,
627            }),
628        }
629    }
630}
631
632impl From<RequestEnvelope> for proto::RequestEnvelope {
633    fn from(value: RequestEnvelope) -> Self {
634        Self {
635            action_name: value.action_name,
636            request_id: value.request_id,
637            params_json: serde_json::to_string(&value.params).expect("json serialization"),
638            caller_role: caller_role_code(value.caller_role),
639            request_hash: value.request_hash.into_inner(),
640        }
641    }
642}
643
644impl TryFrom<proto::RequestEnvelope> for RequestEnvelope {
645    type Error = BridgeError;
646
647    fn try_from(value: proto::RequestEnvelope) -> Result<Self, Self::Error> {
648        let params = serde_json::from_str(&value.params_json)
649            .map_err(|error| BridgeError::InvalidJson("params_json", error))?;
650
651        Ok(Self {
652            action_name: value.action_name,
653            request_id: value.request_id,
654            params,
655            caller_role: CallerRole::try_from(
656                proto::CallerRole::try_from(value.caller_role).map_err(|_| {
657                    BridgeError::InvalidEnum {
658                        field: "caller_role",
659                        value: value.caller_role,
660                    }
661                })?,
662            )?,
663            request_hash: RequestHash::from(value.request_hash),
664        })
665    }
666}
667
668impl From<PreviewEnvelope> for proto::PreviewEnvelope {
669    fn from(value: PreviewEnvelope) -> Self {
670        Self {
671            summary: value.summary,
672            risk_level: risk_level_code(value.risk_level),
673            current_state_json: serde_json::to_string(&value.current_state)
674                .expect("json serialization"),
675            proposed_change_json: serde_json::to_string(&value.proposed_change)
676                .expect("json serialization"),
677            expected_side_effects: value.expected_side_effects,
678            reboot_required: value.reboot_required,
679            rollback_available: value.rollback_available,
680            warnings: value.warnings,
681            request_hash: value.request_hash.into_inner(),
682        }
683    }
684}
685
686impl TryFrom<proto::PreviewEnvelope> for PreviewEnvelope {
687    type Error = BridgeError;
688
689    fn try_from(value: proto::PreviewEnvelope) -> Result<Self, Self::Error> {
690        let current_state = serde_json::from_str(&value.current_state_json)
691            .map_err(|error| BridgeError::InvalidJson("current_state_json", error))?;
692        let proposed_change = serde_json::from_str(&value.proposed_change_json)
693            .map_err(|error| BridgeError::InvalidJson("proposed_change_json", error))?;
694
695        Ok(Self {
696            summary: value.summary,
697            risk_level: RiskLevel::try_from(
698                proto::RiskLevel::try_from(value.risk_level).map_err(|_| {
699                    BridgeError::InvalidEnum {
700                        field: "risk_level",
701                        value: value.risk_level,
702                    }
703                })?,
704            )?,
705            current_state,
706            proposed_change,
707            expected_side_effects: value.expected_side_effects,
708            reboot_required: value.reboot_required,
709            rollback_available: value.rollback_available,
710            warnings: value.warnings,
711            request_hash: RequestHash::from(value.request_hash),
712        })
713    }
714}
715
716impl From<ResultEnvelope> for proto::ResultEnvelope {
717    fn from(value: ResultEnvelope) -> Self {
718        Self {
719            status: job_state_code(value.status),
720            summary: value.summary,
721            warnings: value.warnings,
722            job_id: value.job_id.unwrap_or_default(),
723            needs_reboot: value.needs_reboot,
724            rollback_ref: value.rollback_ref.unwrap_or_default(),
725            transaction_id: value.transaction_id,
726        }
727    }
728}
729
730impl TryFrom<proto::ResultEnvelope> for ResultEnvelope {
731    type Error = BridgeError;
732
733    fn try_from(value: proto::ResultEnvelope) -> Result<Self, Self::Error> {
734        Ok(Self {
735            status: JobState::try_from(proto::JobState::try_from(value.status).map_err(|_| {
736                BridgeError::InvalidEnum {
737                    field: "job_state",
738                    value: value.status,
739                }
740            })?)?,
741            summary: value.summary,
742            warnings: value.warnings,
743            job_id: if value.job_id.is_empty() {
744                None
745            } else {
746                Some(value.job_id)
747            },
748            needs_reboot: value.needs_reboot,
749            rollback_ref: if value.rollback_ref.is_empty() {
750                None
751            } else {
752                Some(value.rollback_ref)
753            },
754            transaction_id: value.transaction_id,
755        })
756    }
757}
758
759impl From<TransactionRecord> for proto::TransactionRecord {
760    fn from(value: TransactionRecord) -> Self {
761        Self {
762            transaction_id: value.transaction_id,
763            request_id: value.request_id,
764            request_hash: value.request_hash,
765            action_name: value.action_name,
766            risk_level: risk_level_code(value.risk_level),
767            status: job_state_code(value.status),
768            approval_id: value.approval_id.unwrap_or_default(),
769            summary: value.summary,
770            warnings: value.warnings,
771        }
772    }
773}
774
775impl TryFrom<proto::TransactionRecord> for TransactionRecord {
776    type Error = BridgeError;
777
778    fn try_from(value: proto::TransactionRecord) -> Result<Self, Self::Error> {
779        Ok(Self {
780            transaction_id: value.transaction_id,
781            request_id: value.request_id,
782            request_hash: value.request_hash,
783            action_name: value.action_name,
784            risk_level: RiskLevel::try_from(
785                proto::RiskLevel::try_from(value.risk_level).map_err(|_| {
786                    BridgeError::InvalidEnum {
787                        field: "risk_level",
788                        value: value.risk_level,
789                    }
790                })?,
791            )?,
792            status: JobState::try_from(proto::JobState::try_from(value.status).map_err(|_| {
793                BridgeError::InvalidEnum {
794                    field: "job_state",
795                    value: value.status,
796                }
797            })?)?,
798            approval_id: if value.approval_id.is_empty() {
799                None
800            } else {
801                Some(value.approval_id)
802            },
803            summary: value.summary,
804            warnings: value.warnings,
805        })
806    }
807}
808
809#[cfg(test)]
810mod request_hash_tests {
811    use super::*;
812
813    #[test]
814    fn serde_round_trips_via_transparent_string() {
815        // The newtype must serialise to a bare string so existing JSON IPC
816        // frames continue to deserialise unchanged.
817        let r = RequestHash::new("abc123".to_string());
818        let json = serde_json::to_string(&r).unwrap();
819        assert_eq!(json, "\"abc123\"");
820        let back: RequestHash = serde_json::from_str("\"abc123\"").unwrap();
821        assert_eq!(back, r);
822    }
823}