Skip to main content

wist_api/action_plan/
v1.rs

1//! `agent/action-plan` seam —— **v1** 基线。
2//!
3//! 冻结基线:只做**加性**兼容不动它;非加性变更就新开 `v2`。
4//! 约定见 `wist-design/doc/design/foundation/api-seam-inventory.md` §7。
5
6use serde::{Deserialize, Serialize};
7
8use wist_contracts::API_VERSION_V1;
9
10use super::ActionPlan;
11
12/// 本版本的线上版本号(与路由 `/api/v1/…` 一致)。
13pub const API_VERSION: &str = API_VERSION_V1;
14
15pub const DISPATCH_ACTION_PLAN_KIND: &str = "dispatch_action_plan";
16pub const ACTION_PLAN_ACK_KIND: &str = "action_plan_ack";
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct DispatchActionPlan {
21    pub api_version: String,
22    pub kind: String,
23    pub dispatch_id: String,
24    pub plan: ActionPlan,
25}
26
27impl DispatchActionPlan {
28    pub fn new(dispatch_id: String, plan: ActionPlan) -> Self {
29        Self {
30            api_version: API_VERSION_V1.to_string(),
31            kind: DISPATCH_ACTION_PLAN_KIND.to_string(),
32            dispatch_id,
33            plan,
34        }
35    }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct ActionPlanAck {
41    pub api_version: String,
42    pub kind: String,
43    pub dispatch_id: String,
44    pub action_id: String,
45    pub plan_digest: String,
46    pub agent_id: String,
47    pub instance_id: String,
48    pub execution_id: Option<String>,
49    pub ack_status: AckStatus,
50    pub reason_code: Option<String>,
51    pub reason_message: Option<String>,
52    pub queue_position: Option<u64>,
53    pub received_at: String,
54    pub acknowledged_at: String,
55}
56
57impl ActionPlanAck {
58    pub fn builder(
59        dispatch_id: String,
60        action_id: String,
61        ack_status: AckStatus,
62    ) -> ActionPlanAckBuilder {
63        ActionPlanAckBuilder {
64            dispatch_id,
65            action_id,
66            plan_digest: String::new(),
67            agent_id: String::new(),
68            instance_id: String::new(),
69            execution_id: None,
70            ack_status,
71            reason_code: None,
72            reason_message: None,
73            queue_position: None,
74            received_at: String::new(),
75            acknowledged_at: String::new(),
76        }
77    }
78
79    #[allow(clippy::too_many_arguments)]
80    pub fn new(
81        dispatch_id: String,
82        action_id: String,
83        plan_digest: String,
84        agent_id: String,
85        instance_id: String,
86        execution_id: Option<String>,
87        ack_status: AckStatus,
88        received_at: String,
89        acknowledged_at: String,
90    ) -> Self {
91        Self::builder(dispatch_id, action_id, ack_status)
92            .plan_digest(plan_digest)
93            .agent_id(agent_id)
94            .instance_id(instance_id)
95            .execution_id(execution_id)
96            .received_at(received_at)
97            .acknowledged_at(acknowledged_at)
98            .build()
99    }
100}
101
102#[derive(Debug, Clone)]
103pub struct ActionPlanAckBuilder {
104    dispatch_id: String,
105    action_id: String,
106    plan_digest: String,
107    agent_id: String,
108    instance_id: String,
109    execution_id: Option<String>,
110    ack_status: AckStatus,
111    reason_code: Option<String>,
112    reason_message: Option<String>,
113    queue_position: Option<u64>,
114    received_at: String,
115    acknowledged_at: String,
116}
117
118impl ActionPlanAckBuilder {
119    pub fn plan_digest(mut self, plan_digest: String) -> Self {
120        self.plan_digest = plan_digest;
121        self
122    }
123
124    pub fn agent_id(mut self, agent_id: String) -> Self {
125        self.agent_id = agent_id;
126        self
127    }
128
129    pub fn instance_id(mut self, instance_id: String) -> Self {
130        self.instance_id = instance_id;
131        self
132    }
133
134    pub fn execution_id(mut self, execution_id: Option<String>) -> Self {
135        self.execution_id = execution_id;
136        self
137    }
138
139    pub fn reason_code(mut self, reason_code: Option<String>) -> Self {
140        self.reason_code = reason_code;
141        self
142    }
143
144    pub fn reason_message(mut self, reason_message: Option<String>) -> Self {
145        self.reason_message = reason_message;
146        self
147    }
148
149    pub fn queue_position(mut self, queue_position: Option<u64>) -> Self {
150        self.queue_position = queue_position;
151        self
152    }
153
154    pub fn received_at(mut self, received_at: String) -> Self {
155        self.received_at = received_at;
156        self
157    }
158
159    pub fn acknowledged_at(mut self, acknowledged_at: String) -> Self {
160        self.acknowledged_at = acknowledged_at;
161        self
162    }
163
164    pub fn build(self) -> ActionPlanAck {
165        ActionPlanAck {
166            api_version: API_VERSION_V1.to_string(),
167            kind: ACTION_PLAN_ACK_KIND.to_string(),
168            dispatch_id: self.dispatch_id,
169            action_id: self.action_id,
170            plan_digest: self.plan_digest,
171            agent_id: self.agent_id,
172            instance_id: self.instance_id,
173            execution_id: self.execution_id,
174            ack_status: self.ack_status,
175            reason_code: self.reason_code,
176            reason_message: self.reason_message,
177            queue_position: self.queue_position,
178            received_at: self.received_at,
179            acknowledged_at: self.acknowledged_at,
180        }
181    }
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
185pub enum AckStatus {
186    #[serde(rename = "accepted")]
187    Accepted,
188    #[serde(rename = "rejected")]
189    Rejected,
190    #[serde(rename = "queued")]
191    Queued,
192    #[serde(rename = "duplicate")]
193    Duplicate,
194    #[serde(rename = "stale")]
195    Stale,
196    #[serde(rename = "busy")]
197    Busy,
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    fn plan() -> ActionPlan {
205        serde_json::from_str(
206            r#"{"api_version":"v1","kind":"action_plan",
207                "meta":{"action_id":"act-1","request_id":"req-1","template_id":null,
208                        "tenant_id":"t","environment_id":"e","plan_version":1,
209                        "compiled_at":"2026-09-27T00:00:00Z","expires_at":"2026-09-28T00:00:00Z"},
210                "target":{"agent_id":"agent-1","instance_id":null,"node_id":"n","host_name":null,
211                          "platform":"macos","arch":"arm64","selectors":{}},
212                "constraints":{"risk_level":"R1","approval_ref":null,"approval_mode":"not_required",
213                               "requested_by":"admin","reason":null,"max_total_duration_ms":1000,
214                               "step_timeout_default_ms":500,"execution_profile":"default",
215                               "required_capabilities":[]},
216                "program":{"entry":"s1","steps":[{"id":"s1","kind":"invoke","op":"shell"}]}}"#,
217        )
218        .expect("plan")
219    }
220
221    #[test]
222    fn dispatch_action_plan_new_stamps_the_envelope() {
223        let dispatch = DispatchActionPlan::new("disp-1".to_string(), plan());
224        assert_eq!(dispatch.api_version, API_VERSION_V1);
225        assert_eq!(dispatch.kind, DISPATCH_ACTION_PLAN_KIND);
226
227        let json = serde_json::to_string(&dispatch).expect("encode");
228        let back: DispatchActionPlan = serde_json::from_str(&json).expect("decode");
229        assert_eq!(back, dispatch);
230    }
231
232    #[test]
233    fn action_plan_ack_builder_and_new_stamp_the_envelope() {
234        let built =
235            ActionPlanAck::builder("disp-1".to_string(), "act-1".to_string(), AckStatus::Queued)
236                .plan_digest("sha256:plan".to_string())
237                .agent_id("agent-1".to_string())
238                .instance_id("inst-1".to_string())
239                .queue_position(Some(3))
240                .received_at("2026-09-27T00:00:00Z".to_string())
241                .acknowledged_at("2026-09-27T00:00:01Z".to_string())
242                .build();
243        assert_eq!(built.api_version, API_VERSION_V1);
244        assert_eq!(built.kind, ACTION_PLAN_ACK_KIND);
245        assert_eq!(built.queue_position, Some(3));
246        assert_eq!(built.reason_code, None);
247
248        let constructed = ActionPlanAck::new(
249            "disp-1".to_string(),
250            "act-1".to_string(),
251            "sha256:plan".to_string(),
252            "agent-1".to_string(),
253            "inst-1".to_string(),
254            None,
255            AckStatus::Accepted,
256            "2026-09-27T00:00:00Z".to_string(),
257            "2026-09-27T00:00:01Z".to_string(),
258        );
259        assert_eq!(constructed.kind, ACTION_PLAN_ACK_KIND);
260
261        let json = serde_json::to_string(&constructed).expect("encode");
262        let back: ActionPlanAck = serde_json::from_str(&json).expect("decode");
263        assert_eq!(back, constructed);
264    }
265
266    #[test]
267    fn ack_status_uses_the_wire_names_and_rejects_unknown_variants() {
268        for (status, name) in [
269            (AckStatus::Accepted, "accepted"),
270            (AckStatus::Rejected, "rejected"),
271            (AckStatus::Queued, "queued"),
272            (AckStatus::Duplicate, "duplicate"),
273            (AckStatus::Stale, "stale"),
274            (AckStatus::Busy, "busy"),
275        ] {
276            assert_eq!(
277                serde_json::to_string(&status).unwrap(),
278                format!("\"{name}\"")
279            );
280        }
281        assert!(serde_json::from_str::<AckStatus>("\"unknown\"").is_err());
282    }
283}