Skip to main content

wist_contracts/
action_plan.rs

1//! `ActionPlan` contract types.
2
3use 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        // 封闭集:未知变体必须报错,不能静默接受。
213        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}