Skip to main content

aidens_contracts/
daemon_queue.rs

1//! Queue, schedule, wake, daemon, and safe-mode artifacts.
2//!
3//! These DTOs describe supported-local orchestration state only.
4
5use super::*;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
8#[serde(rename_all = "kebab-case")]
9pub enum JobStateV1 {
10    Queued,
11    Leased,
12    Running,
13    Completed,
14    Retrying,
15    Cancelled,
16    Poisoned,
17}
18
19impl JobStateV1 {
20    pub fn is_terminal(&self) -> bool {
21        matches!(self, Self::Completed | Self::Cancelled | Self::Poisoned)
22    }
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26#[serde(rename_all = "kebab-case")]
27pub enum QueueHopKindV1 {
28    Enqueued,
29    LeaseAcquired,
30    LeaseStolen,
31    Executed,
32    Retried,
33    Cancelled,
34    DuplicateSuppressed,
35    SafeModeBlocked,
36    Drained,
37    Poisoned,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
41#[serde(rename_all = "kebab-case")]
42pub enum SafeModeOperationV1 {
43    Entered,
44    Exited,
45    BlockedRiskyJob,
46    DrainAllowed,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
50pub struct JobV1 {
51    pub job_id: ArtifactId,
52    pub kind: ArtifactKindV1,
53    pub namespace_id: ArtifactId,
54    pub idempotency_key: String,
55    pub attempt_family_id: ArtifactId,
56    pub source_kind: String,
57    pub occurrence_id: Option<ArtifactId>,
58    pub wake_signal_id: Option<ArtifactId>,
59    pub payload: serde_json::Value,
60    pub payload_digest: String,
61    pub risk: CanonicalToolSideEffectClass,
62    pub state: JobStateV1,
63    pub lease_id: Option<ArtifactId>,
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub reason_codes: Vec<String>,
66    pub created_at: DateTime<Utc>,
67    pub updated_at: DateTime<Utc>,
68    pub cancelled_at: Option<DateTime<Utc>>,
69}
70
71impl JobV1 {
72    pub fn new(
73        namespace_id: ArtifactId,
74        idempotency_key: impl Into<String>,
75        source_kind: impl Into<String>,
76        payload: serde_json::Value,
77        risk: CanonicalToolSideEffectClass,
78        occurrence_id: Option<ArtifactId>,
79        wake_signal_id: Option<ArtifactId>,
80    ) -> Self {
81        let idempotency_key = idempotency_key.into();
82        let payload_digest = non_authoritative_json_display_digest(&payload);
83        let identity_material =
84            format!("{}|{}|{}", namespace_id.0, idempotency_key, payload_digest);
85        let now = Utc::now();
86        Self {
87            job_id: local_artifact_id_from_stack_digest("job", &identity_material),
88            kind: ArtifactKindV1::Job,
89            namespace_id,
90            attempt_family_id: local_artifact_id_from_stack_digest(
91                "attempt-family",
92                &format!("job-attempt-family|{identity_material}"),
93            ),
94            idempotency_key,
95            source_kind: source_kind.into(),
96            occurrence_id,
97            wake_signal_id,
98            payload,
99            payload_digest,
100            risk,
101            state: JobStateV1::Queued,
102            lease_id: None,
103            reason_codes: Vec::new(),
104            created_at: now,
105            updated_at: now,
106            cancelled_at: None,
107        }
108    }
109
110    pub fn with_state(
111        mut self,
112        state: JobStateV1,
113        lease_id: Option<ArtifactId>,
114        reason: impl Into<String>,
115    ) -> Self {
116        let reason = reason.into();
117        if !reason.trim().is_empty() {
118            self.reason_codes.push(reason);
119        }
120        if state == JobStateV1::Cancelled {
121            self.cancelled_at = Some(Utc::now());
122        }
123        self.state = state;
124        self.lease_id = lease_id;
125        self.updated_at = Utc::now();
126        self
127    }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
131pub struct QueueLeaseV1 {
132    pub lease_id: ArtifactId,
133    pub kind: ArtifactKindV1,
134    pub namespace_id: ArtifactId,
135    pub job_id: ArtifactId,
136    pub attempt_family_id: ArtifactId,
137    pub owner: String,
138    pub acquired_at: DateTime<Utc>,
139    pub expires_at: DateTime<Utc>,
140    pub stolen_from: Option<ArtifactId>,
141    pub active: bool,
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub reason_codes: Vec<String>,
144}
145
146impl QueueLeaseV1 {
147    pub fn new(
148        job: &JobV1,
149        owner: impl Into<String>,
150        ttl_seconds: i64,
151        stolen_from: Option<ArtifactId>,
152    ) -> Self {
153        let acquired_at = Utc::now();
154        let ttl_seconds = ttl_seconds.max(1);
155        let owner = owner.into();
156        let acquired_at_material = acquired_at.to_rfc3339_opts(SecondsFormat::Nanos, true);
157        let material = format!(
158            "{}|{}|{}|{}",
159            job.namespace_id.0, job.job_id.0, owner, acquired_at_material
160        );
161        Self {
162            lease_id: local_artifact_id_from_stack_digest("queue-lease", &material),
163            kind: ArtifactKindV1::QueueLease,
164            namespace_id: job.namespace_id.clone(),
165            job_id: job.job_id.clone(),
166            attempt_family_id: job.attempt_family_id.clone(),
167            owner,
168            acquired_at,
169            expires_at: acquired_at + Duration::seconds(ttl_seconds),
170            stolen_from,
171            active: true,
172            reason_codes: vec!["lease-acquired".into()],
173        }
174    }
175
176    pub fn is_expired_at(&self, at: DateTime<Utc>) -> bool {
177        self.expires_at <= at
178    }
179}
180
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
182pub struct ScheduleOccurrenceV1 {
183    pub occurrence_id: ArtifactId,
184    pub kind: ArtifactKindV1,
185    pub namespace_id: ArtifactId,
186    pub schedule_id: String,
187    pub occurrence_key: String,
188    pub due_at: DateTime<Utc>,
189    pub idempotency_key: String,
190    pub payload: serde_json::Value,
191    pub payload_digest: String,
192    pub risk: CanonicalToolSideEffectClass,
193    pub created_at: DateTime<Utc>,
194}
195
196impl ScheduleOccurrenceV1 {
197    pub fn new(
198        namespace_id: ArtifactId,
199        schedule_id: impl Into<String>,
200        occurrence_key: impl Into<String>,
201        due_at: DateTime<Utc>,
202        payload: serde_json::Value,
203        risk: CanonicalToolSideEffectClass,
204    ) -> Self {
205        let schedule_id = schedule_id.into();
206        let occurrence_key = occurrence_key.into();
207        let payload_digest = non_authoritative_json_display_digest(&payload);
208        let idempotency_key = format!(
209            "schedule:{}:{}:{}:{}",
210            namespace_id.0, schedule_id, occurrence_key, payload_digest
211        );
212        Self {
213            occurrence_id: local_artifact_id_from_stack_digest(
214                "schedule-occurrence",
215                &idempotency_key,
216            ),
217            kind: ArtifactKindV1::ScheduleOccurrence,
218            namespace_id,
219            schedule_id,
220            occurrence_key,
221            due_at,
222            idempotency_key,
223            payload,
224            payload_digest,
225            risk,
226            created_at: Utc::now(),
227        }
228    }
229
230    pub fn identity_is_not_timestamp_only(&self) -> bool {
231        !self.schedule_id.trim().is_empty()
232            && !self.occurrence_key.trim().is_empty()
233            && self.idempotency_key.contains(&self.schedule_id)
234            && self.idempotency_key.contains(&self.occurrence_key)
235            && self.idempotency_key.contains(&self.payload_digest)
236    }
237}
238
239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
240pub struct WakeSignalV1 {
241    pub signal_id: ArtifactId,
242    pub kind: ArtifactKindV1,
243    pub namespace_id: ArtifactId,
244    pub source: String,
245    pub signal_key: String,
246    pub idempotency_key: String,
247    pub payload: serde_json::Value,
248    pub payload_digest: String,
249    pub risk: CanonicalToolSideEffectClass,
250    pub received_at: DateTime<Utc>,
251}
252
253impl WakeSignalV1 {
254    pub fn new(
255        namespace_id: ArtifactId,
256        source: impl Into<String>,
257        signal_key: impl Into<String>,
258        payload: serde_json::Value,
259        risk: CanonicalToolSideEffectClass,
260    ) -> Self {
261        let source = source.into();
262        let signal_key = signal_key.into();
263        let payload_digest = non_authoritative_json_display_digest(&payload);
264        let idempotency_key = format!(
265            "wake:{}:{}:{}:{}",
266            namespace_id.0, source, signal_key, payload_digest
267        );
268        Self {
269            signal_id: local_artifact_id_from_stack_digest("wake-signal", &idempotency_key),
270            kind: ArtifactKindV1::WakeSignal,
271            namespace_id,
272            source,
273            signal_key,
274            idempotency_key,
275            payload,
276            payload_digest,
277            risk,
278            received_at: Utc::now(),
279        }
280    }
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
284pub struct DaemonNamespaceV1 {
285    pub namespace_id: ArtifactId,
286    pub kind: ArtifactKindV1,
287    pub name: String,
288    pub queue_root: String,
289    pub daemon_owner: String,
290    pub safe_mode_enabled: bool,
291    pub max_lease_seconds: i64,
292    pub idempotency_scope: String,
293    pub created_at: DateTime<Utc>,
294}
295
296impl DaemonNamespaceV1 {
297    pub fn new(
298        name: impl Into<String>,
299        queue_root: impl Into<String>,
300        daemon_owner: impl Into<String>,
301    ) -> Self {
302        let name = name.into();
303        let queue_root = queue_root.into();
304        let daemon_owner = daemon_owner.into();
305        let namespace_material = format!("{name}|{queue_root}|{daemon_owner}");
306        Self {
307            namespace_id: local_artifact_id_from_stack_digest(
308                "daemon-namespace",
309                &namespace_material,
310            ),
311            kind: ArtifactKindV1::DaemonNamespace,
312            name,
313            queue_root,
314            daemon_owner,
315            safe_mode_enabled: false,
316            max_lease_seconds: 300,
317            idempotency_scope: "namespace-id-plus-idempotency-key-plus-payload-digest".into(),
318            created_at: Utc::now(),
319        }
320    }
321
322    pub fn with_safe_mode(mut self, enabled: bool) -> Self {
323        self.safe_mode_enabled = enabled;
324        self
325    }
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
329pub struct SafeModeReportV1 {
330    pub receipt_id: ArtifactId,
331    pub kind: ArtifactKindV1,
332    pub namespace_id: ArtifactId,
333    pub operation: SafeModeOperationV1,
334    pub enabled: bool,
335    pub affected_job_id: Option<ArtifactId>,
336    pub new_risky_jobs_blocked: bool,
337    pub inspection_allowed: bool,
338    pub drain_allowed: bool,
339    #[serde(default, skip_serializing_if = "Vec::is_empty")]
340    pub reason_codes: Vec<String>,
341    pub recorded_at: DateTime<Utc>,
342}
343
344impl SafeModeReportV1 {
345    pub fn new(
346        namespace_id: ArtifactId,
347        operation: SafeModeOperationV1,
348        enabled: bool,
349        affected_job_id: Option<ArtifactId>,
350        reason: impl Into<String>,
351    ) -> Self {
352        Self {
353            receipt_id: display_only_unstable_id("safe-mode"),
354            kind: ArtifactKindV1::SafeMode,
355            namespace_id,
356            operation,
357            enabled,
358            affected_job_id,
359            new_risky_jobs_blocked: enabled,
360            inspection_allowed: true,
361            drain_allowed: true,
362            reason_codes: vec![reason.into()],
363            recorded_at: Utc::now(),
364        }
365    }
366
367    pub fn blocks_new_risky_jobs_but_allows_drain(&self) -> bool {
368        self.new_risky_jobs_blocked && self.inspection_allowed && self.drain_allowed
369    }
370}
371
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
373pub struct DuplicateSuppressionReportV1 {
374    pub receipt_id: ArtifactId,
375    pub kind: ArtifactKindV1,
376    pub namespace_id: ArtifactId,
377    pub idempotency_key: String,
378    pub existing_job_id: ArtifactId,
379    pub suppressed_source_kind: String,
380    #[serde(default, skip_serializing_if = "Vec::is_empty")]
381    pub reason_codes: Vec<String>,
382    pub recorded_at: DateTime<Utc>,
383}
384
385impl DuplicateSuppressionReportV1 {
386    pub fn new(
387        namespace_id: ArtifactId,
388        idempotency_key: impl Into<String>,
389        existing_job_id: ArtifactId,
390        suppressed_source_kind: impl Into<String>,
391    ) -> Self {
392        Self {
393            receipt_id: display_only_unstable_id("duplicate-suppression"),
394            kind: ArtifactKindV1::DuplicateSuppression,
395            namespace_id,
396            idempotency_key: idempotency_key.into(),
397            existing_job_id,
398            suppressed_source_kind: suppressed_source_kind.into(),
399            reason_codes: vec!["duplicate-logical-job-suppressed".into()],
400            recorded_at: Utc::now(),
401        }
402    }
403}
404
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
406pub struct QueueHopReportV1 {
407    pub receipt_id: ArtifactId,
408    pub kind: ArtifactKindV1,
409    pub namespace_id: ArtifactId,
410    pub job_id: ArtifactId,
411    pub lease_id: Option<ArtifactId>,
412    pub hop: QueueHopKindV1,
413    pub from_state: Option<JobStateV1>,
414    pub to_state: JobStateV1,
415    #[serde(default, skip_serializing_if = "Vec::is_empty")]
416    pub reason_codes: Vec<String>,
417    pub recorded_at: DateTime<Utc>,
418}
419
420impl QueueHopReportV1 {
421    pub fn new(
422        namespace_id: ArtifactId,
423        job_id: ArtifactId,
424        lease_id: Option<ArtifactId>,
425        hop: QueueHopKindV1,
426        from_state: Option<JobStateV1>,
427        to_state: JobStateV1,
428        reason: impl Into<String>,
429    ) -> Self {
430        Self {
431            receipt_id: display_only_unstable_id("queue-hop"),
432            kind: ArtifactKindV1::QueueHop,
433            namespace_id,
434            job_id,
435            lease_id,
436            hop,
437            from_state,
438            to_state,
439            reason_codes: vec![reason.into()],
440            recorded_at: Utc::now(),
441        }
442    }
443}