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