1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::API_VERSION_V1;
8
9pub const ACTION_PLAN_KIND: &str = "action_plan";
10pub const STEP_KIND_INVOKE: &str = "invoke";
11pub const STEP_KIND_BRANCH: &str = "branch";
12pub const STEP_KIND_GUARD: &str = "guard";
13pub const STEP_KIND_OUTPUT: &str = "output";
14pub const STEP_KIND_ABORT: &str = "abort";
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
17#[jumo(kind = "struct", domain = "Reporting", module = "Reporting.Contract")]
18#[serde(deny_unknown_fields)]
19pub struct ActionPlan {
20 pub api_version: String,
21 pub kind: String,
22 pub meta: ActionPlanMeta,
23 pub target: ActionPlanTarget,
24 pub constraints: ActionPlanConstraints,
25 pub program: ActionPlanProgram,
26}
27
28impl ActionPlan {
29 pub fn new(
30 meta: ActionPlanMeta,
31 target: ActionPlanTarget,
32 constraints: ActionPlanConstraints,
33 program: ActionPlanProgram,
34 ) -> Self {
35 Self {
36 api_version: API_VERSION_V1.to_string(),
37 kind: ACTION_PLAN_KIND.to_string(),
38 meta,
39 target,
40 constraints,
41 program,
42 }
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
47#[jumo(kind = "struct", domain = "Reporting", module = "Reporting.Contract")]
48#[serde(deny_unknown_fields)]
49pub struct ActionPlanMeta {
50 pub action_id: String,
51 pub request_id: String,
52 pub template_id: Option<String>,
53 pub tenant_id: String,
54 pub environment_id: String,
55 pub plan_version: i64,
56 pub compiled_at: String,
57 pub expires_at: String,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
61#[jumo(kind = "struct", domain = "Reporting", module = "Reporting.Contract")]
62#[serde(deny_unknown_fields)]
63pub struct ActionPlanTarget {
64 pub agent_id: String,
65 pub instance_id: Option<String>,
66 pub node_id: String,
67 pub host_name: Option<String>,
68 pub platform: String,
69 pub arch: String,
70 #[serde(default)]
71 pub selectors: BTreeMap<String, String>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
75#[jumo(kind = "struct", domain = "Reporting", module = "Reporting.Contract")]
76#[serde(deny_unknown_fields)]
77pub struct ActionPlanConstraints {
78 pub risk_level: RiskLevel,
79 pub approval_ref: Option<String>,
80 pub approval_mode: ApprovalMode,
81 pub requested_by: String,
82 pub reason: Option<String>,
83 pub max_total_duration_ms: u64,
84 pub step_timeout_default_ms: u64,
85 pub execution_profile: String,
86 #[serde(default)]
87 pub required_capabilities: Vec<String>,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91pub enum RiskLevel {
92 #[serde(rename = "R0")]
93 R0,
94 #[serde(rename = "R1")]
95 R1,
96 #[serde(rename = "R2")]
97 R2,
98 #[serde(rename = "R3")]
99 R3,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103pub enum ApprovalMode {
104 #[serde(rename = "not_required")]
105 NotRequired,
106 #[serde(rename = "required")]
107 Required,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
111#[jumo(kind = "struct", domain = "Reporting", module = "Reporting.Contract")]
112#[serde(deny_unknown_fields)]
113pub struct ActionPlanProgram {
114 pub entry: String,
115 pub steps: Vec<ActionPlanStep>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
119#[jumo(kind = "struct", domain = "Reporting", module = "Reporting.Contract")]
120#[serde(deny_unknown_fields)]
121pub struct ActionPlanStep {
122 pub id: String,
123 pub kind: String,
124 pub op: Option<String>,
125}
126
127pub fn is_known_step_kind(kind: &str) -> bool {
128 matches!(
129 kind,
130 STEP_KIND_INVOKE | STEP_KIND_BRANCH | STEP_KIND_GUARD | STEP_KIND_OUTPUT | STEP_KIND_ABORT
131 )
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use crate::API_VERSION_V1;
138
139 fn sample() -> ActionPlan {
140 ActionPlan::new(
141 ActionPlanMeta {
142 action_id: "act-1".to_string(),
143 request_id: "req-1".to_string(),
144 template_id: None,
145 tenant_id: "tenant-a".to_string(),
146 environment_id: "env-a".to_string(),
147 plan_version: 1,
148 compiled_at: "2026-09-27T00:00:00Z".to_string(),
149 expires_at: "2026-09-28T00:00:00Z".to_string(),
150 },
151 ActionPlanTarget {
152 agent_id: "agent-1".to_string(),
153 instance_id: None,
154 node_id: "node-1".to_string(),
155 host_name: None,
156 platform: "macos".to_string(),
157 arch: "arm64".to_string(),
158 selectors: BTreeMap::new(),
159 },
160 ActionPlanConstraints {
161 risk_level: RiskLevel::R1,
162 approval_ref: None,
163 approval_mode: ApprovalMode::NotRequired,
164 requested_by: "admin".to_string(),
165 reason: None,
166 max_total_duration_ms: 60_000,
167 step_timeout_default_ms: 5_000,
168 execution_profile: "default".to_string(),
169 required_capabilities: vec!["collect_logs".to_string()],
170 },
171 ActionPlanProgram {
172 entry: "step-1".to_string(),
173 steps: vec![ActionPlanStep {
174 id: "step-1".to_string(),
175 kind: STEP_KIND_INVOKE.to_string(),
176 op: Some("shell".to_string()),
177 }],
178 },
179 )
180 }
181
182 #[test]
183 fn new_stamps_the_contract_version_and_kind() {
184 let plan = sample();
185 assert_eq!(plan.api_version, API_VERSION_V1);
186 assert_eq!(plan.kind, ACTION_PLAN_KIND);
187 }
188
189 #[test]
190 fn action_plan_round_trips_and_rejects_unknown_fields() {
191 let plan = sample();
192 let json = serde_json::to_string(&plan).expect("encode");
193 let back: ActionPlan = serde_json::from_str(&json).expect("decode");
194 assert_eq!(back, plan);
195
196 let with_extra = json.replacen('{', "{\"extra\":1,", 1);
197 assert!(serde_json::from_str::<ActionPlan>(&with_extra).is_err());
198 }
199
200 #[test]
201 fn risk_and_approval_use_the_model_wire_names() {
202 assert_eq!(serde_json::to_string(&RiskLevel::R0).unwrap(), "\"R0\"");
203 assert_eq!(serde_json::to_string(&RiskLevel::R3).unwrap(), "\"R3\"");
204 assert_eq!(
205 serde_json::to_string(&ApprovalMode::NotRequired).unwrap(),
206 "\"not_required\""
207 );
208 assert_eq!(
209 serde_json::to_string(&ApprovalMode::Required).unwrap(),
210 "\"required\""
211 );
212 assert!(serde_json::from_str::<RiskLevel>("\"R9\"").is_err());
214 assert!(serde_json::from_str::<ApprovalMode>("\"maybe\"").is_err());
215 }
216
217 #[test]
218 fn known_step_kinds_are_exactly_the_closed_set() {
219 for kind in [
220 STEP_KIND_INVOKE,
221 STEP_KIND_BRANCH,
222 STEP_KIND_GUARD,
223 STEP_KIND_OUTPUT,
224 STEP_KIND_ABORT,
225 ] {
226 assert!(is_known_step_kind(kind), "{kind}");
227 }
228 assert!(!is_known_step_kind("nope"));
229 assert!(!is_known_step_kind(""));
230 }
231}