1use serde::{Deserialize, Serialize};
7
8use wist_contracts::API_VERSION_V1;
9
10use super::ActionPlan;
11
12pub 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}