1use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use crate::{DurableControlReceipt, ManagedRunTarget, RunAdmissionLease};
13
14#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
16#[serde(tag = "kind", rename_all = "snake_case")]
17pub enum DurableRunControlEffect {
18 Steer {
20 text: String,
22 },
23 Interrupt {
25 #[serde(default, skip_serializing_if = "Option::is_none")]
27 reason: Option<String>,
28 },
29}
30
31impl DurableRunControlEffect {
32 #[must_use]
34 pub const fn operation(&self) -> &'static str {
35 match self {
36 Self::Steer { .. } => "steer",
37 Self::Interrupt { .. } => "interrupt",
38 }
39 }
40}
41
42#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
44#[serde(rename_all = "snake_case")]
45pub enum DurableRunControlStatus {
46 Pending,
48 Delivered,
50 Consumed,
52 Reconciled,
54}
55
56impl DurableRunControlStatus {
57 #[must_use]
59 pub const fn as_str(self) -> &'static str {
60 match self {
61 Self::Pending => "pending",
62 Self::Delivered => "delivered",
63 Self::Consumed => "consumed",
64 Self::Reconciled => "reconciled",
65 }
66 }
67
68 #[must_use]
70 pub const fn can_advance_to(self, next: Self) -> bool {
71 self as u8 == next as u8
72 || matches!(
73 (self, next),
74 (Self::Pending, Self::Delivered | Self::Reconciled)
75 | (Self::Delivered, Self::Consumed | Self::Reconciled)
76 | (Self::Consumed, Self::Reconciled)
77 )
78 }
79}
80
81#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
83pub struct AdmitRunControl {
84 pub lease: RunAdmissionLease,
86 pub authority_binding: String,
88 pub operation_id: String,
90 pub receipt_id: String,
92 pub idempotency_key: String,
94 pub command_fingerprint: String,
96 pub effect: DurableRunControlEffect,
98 pub created_at: DateTime<Utc>,
100}
101
102impl AdmitRunControl {
103 #[must_use]
105 pub fn into_intent(self) -> DurableRunControlIntent {
106 let receipt = DurableControlReceipt {
107 receipt_id: self.receipt_id,
108 target: self.lease.target.clone(),
109 operation_id: self.operation_id.clone(),
110 operation: self.effect.operation().to_string(),
111 idempotency_key: self.idempotency_key,
112 command_fingerprint: self.command_fingerprint,
113 fencing_generation: self.lease.fencing_generation,
114 state: DurableRunControlStatus::Pending.as_str().to_string(),
115 created_at: self.created_at,
116 };
117 DurableRunControlIntent {
118 operation_id: self.operation_id,
119 target: self.lease.target,
120 authority_binding: self.authority_binding,
121 admission_id: self.lease.admission_id,
122 host_instance_id: self.lease.host_instance_id,
123 fencing_generation: self.lease.fencing_generation,
124 idempotency_key: receipt.idempotency_key.clone(),
125 command_fingerprint: receipt.command_fingerprint.clone(),
126 receipt,
127 effect: self.effect,
128 status: DurableRunControlStatus::Pending,
129 created_at: self.created_at,
130 delivered_at: None,
131 consumed_at: None,
132 reconciled_at: None,
133 }
134 }
135}
136
137#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
139pub struct DurableRunControlIntent {
140 pub operation_id: String,
142 pub target: ManagedRunTarget,
144 pub authority_binding: String,
146 pub admission_id: String,
148 pub host_instance_id: String,
150 pub fencing_generation: u64,
152 pub idempotency_key: String,
154 pub command_fingerprint: String,
156 pub receipt: DurableControlReceipt,
158 pub effect: DurableRunControlEffect,
160 pub status: DurableRunControlStatus,
162 pub created_at: DateTime<Utc>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub delivered_at: Option<DateTime<Utc>>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub consumed_at: Option<DateTime<Utc>>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub reconciled_at: Option<DateTime<Utc>>,
173}
174
175impl starweaver_core::VersionedRecord for DurableRunControlIntent {
176 const SCHEMA: &'static str = "starweaver.session.durable_run_control_intent";
177}
178
179impl DurableRunControlIntent {
180 #[must_use]
182 pub fn matches_admission(&self, request: &AdmitRunControl) -> bool {
183 self.target == request.lease.target
184 && self.authority_binding == request.authority_binding
185 && self.admission_id == request.lease.admission_id
186 && self.host_instance_id == request.lease.host_instance_id
187 && self.fencing_generation == request.lease.fencing_generation
188 && self.operation_id == request.operation_id
189 && self.receipt.receipt_id == request.receipt_id
190 && self.idempotency_key == request.idempotency_key
191 && self.command_fingerprint == request.command_fingerprint
192 && self.effect == request.effect
193 }
194
195 pub fn advance(
201 &mut self,
202 next: DurableRunControlStatus,
203 occurred_at: DateTime<Utc>,
204 ) -> Result<(), &'static str> {
205 if !self.status.can_advance_to(next) {
206 return Err("invalid durable run control state transition");
207 }
208 if self.status == next {
209 return Ok(());
210 }
211 self.status = next;
212 self.receipt.state = next.as_str().to_string();
213 match next {
214 DurableRunControlStatus::Pending => {}
215 DurableRunControlStatus::Delivered => self.delivered_at = Some(occurred_at),
216 DurableRunControlStatus::Consumed => {
217 self.delivered_at.get_or_insert(occurred_at);
218 self.consumed_at = Some(occurred_at);
219 }
220 DurableRunControlStatus::Reconciled => self.reconciled_at = Some(occurred_at),
221 }
222 Ok(())
223 }
224}
225
226#[must_use]
228pub fn deterministic_run_control_operation_id(
229 operation: &str,
230 authority_binding: &str,
231 target: &ManagedRunTarget,
232 idempotency_key: &str,
233 command_fingerprint: &str,
234) -> String {
235 let mut digest = Sha256::new();
236 for component in [
237 "starweaver.session.run_control.operation.v1",
238 operation,
239 authority_binding,
240 target.namespace_id.as_str(),
241 target.session_id.as_str(),
242 target.run_id.as_str(),
243 idempotency_key,
244 command_fingerprint,
245 ] {
246 digest.update(component.len().to_be_bytes());
247 digest.update(component.as_bytes());
248 }
249 format!("run_control_{:x}", digest.finalize())
250}
251
252#[must_use]
254pub fn deterministic_run_control_receipt_id(operation_id: &str) -> String {
255 let mut digest = Sha256::new();
256 digest.update(b"starweaver.session.run_control.receipt.v1\0");
257 digest.update(operation_id.as_bytes());
258 format!("control_{:x}", digest.finalize())
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use starweaver_core::{RunId, SessionId};
265
266 #[test]
267 fn operation_identity_binds_authority_target_key_and_fingerprint() {
268 let target = ManagedRunTarget::new(
269 "local",
270 SessionId::from_string("session-a"),
271 RunId::from_string("run-a"),
272 );
273 let first = deterministic_run_control_operation_id(
274 "steer",
275 "authority-a",
276 &target,
277 "key-a",
278 "sha256:a",
279 );
280 assert_eq!(
281 first,
282 deterministic_run_control_operation_id(
283 "steer",
284 "authority-a",
285 &target,
286 "key-a",
287 "sha256:a"
288 )
289 );
290 assert_ne!(
291 first,
292 deterministic_run_control_operation_id(
293 "steer",
294 "authority-b",
295 &target,
296 "key-a",
297 "sha256:a"
298 )
299 );
300 assert_ne!(
301 first,
302 deterministic_run_control_operation_id(
303 "interrupt",
304 "authority-a",
305 &target,
306 "key-a",
307 "sha256:a"
308 )
309 );
310 }
311
312 #[test]
313 fn control_states_are_monotonic() {
314 assert!(
315 DurableRunControlStatus::Pending.can_advance_to(DurableRunControlStatus::Delivered)
316 );
317 assert!(
318 DurableRunControlStatus::Delivered.can_advance_to(DurableRunControlStatus::Consumed)
319 );
320 assert!(
321 DurableRunControlStatus::Consumed.can_advance_to(DurableRunControlStatus::Reconciled)
322 );
323 assert!(
324 !DurableRunControlStatus::Consumed.can_advance_to(DurableRunControlStatus::Pending)
325 );
326 }
327}