1use 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}