1use serde::{Deserialize, Serialize};
4
5use std::future::Future;
6use std::pin::Pin;
7
8use crate::comms::{PeerAddress, PeerId, TrustedPeerDescriptor};
9use crate::lifecycle::{RunId, WaitRequestId};
10pub use crate::ops::{OperationId, OperationResult};
11use crate::runtime_epoch::EpochCursorState;
12use crate::types::SessionId;
13
14pub const DEFAULT_MAX_COMPLETED: usize = 256;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum OperationKind {
21 MobMemberChild,
22 BackgroundToolOp,
23 BackgroundToolCapacitySlot,
24 DetachedJobWait,
25}
26
27impl OperationKind {
28 pub const ALL: [Self; 4] = [
30 Self::MobMemberChild,
31 Self::BackgroundToolOp,
32 Self::BackgroundToolCapacitySlot,
33 Self::DetachedJobWait,
34 ];
35
36 pub const fn generated_variant(self) -> &'static str {
38 match self {
39 Self::MobMemberChild => "MobMemberChild",
40 Self::BackgroundToolOp => "BackgroundToolOp",
41 Self::BackgroundToolCapacitySlot => "BackgroundToolCapacitySlot",
42 Self::DetachedJobWait => "DetachedJobWait",
43 }
44 }
45
46 pub fn expects_peer_channel(self) -> bool {
48 matches!(self, Self::MobMemberChild)
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(tag = "kind", rename_all = "snake_case")]
55pub enum OperationSource {
56 SessionChild {
57 session_id: SessionId,
58 },
59 BackendPeer {
60 peer_id: PeerId,
61 address: PeerAddress,
62 },
63 DetachedJob {
64 realm_id: String,
65 job_id: String,
66 },
67}
68
69impl OperationSource {
70 pub fn session_child(session_id: SessionId) -> Self {
71 Self::SessionChild { session_id }
72 }
73
74 pub fn backend_peer(peer_id: PeerId, address: PeerAddress) -> Self {
75 Self::BackendPeer { peer_id, address }
76 }
77
78 pub fn detached_job(realm_id: impl Into<String>, job_id: impl Into<String>) -> Self {
79 Self::DetachedJob {
80 realm_id: realm_id.into(),
81 job_id: job_id.into(),
82 }
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct OperationSpec {
89 pub id: OperationId,
90 pub kind: OperationKind,
91 pub owner_session_id: SessionId,
96 pub display_name: String,
97 pub source_label: String,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub operation_source: Option<OperationSource>,
100 pub child_session_id: Option<SessionId>,
101 pub expect_peer_channel: bool,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct OperationPeerHandle {
107 pub peer_name: crate::comms::PeerName,
108 pub trusted_peer: TrustedPeerDescriptor,
109}
110
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct OperationProgressUpdate {
114 pub message: String,
115 pub percent: Option<f32>,
116}
117
118#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(tag = "outcome_type", rename_all = "snake_case")]
127pub enum OperationTerminalOutcome {
128 Completed(OperationResult),
129 Failed {
130 error: String,
131 },
132 Aborted {
133 reason: Option<String>,
134 },
135 Cancelled {
136 reason: Option<String>,
137 },
138 #[default]
139 Retired,
140 Terminated {
141 reason: String,
142 },
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
147#[serde(rename_all = "snake_case")]
148pub enum OperationStatus {
149 Absent,
150 Provisioning,
151 Running,
152 Retiring,
153 Completed,
154 Failed,
155 Aborted,
156 Cancelled,
157 Retired,
158 Terminated,
159}
160
161impl OperationStatus {
162 pub fn as_str(self) -> &'static str {
167 match self {
168 Self::Absent => "absent",
169 Self::Provisioning => "provisioning",
170 Self::Running => "running",
171 Self::Retiring => "retiring",
172 Self::Completed => "completed",
173 Self::Failed => "failed",
174 Self::Aborted => "aborted",
175 Self::Cancelled => "cancelled",
176 Self::Retired => "retired",
177 Self::Terminated => "terminated",
178 }
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum OperationPublicResultClass {
190 MissingAuthority,
191 Running,
192 Completed,
193 Failed,
194 Cancelled,
195}
196
197impl OperationPublicResultClass {
198 pub const ALL: [Self; 5] = [
200 Self::MissingAuthority,
201 Self::Running,
202 Self::Completed,
203 Self::Failed,
204 Self::Cancelled,
205 ];
206
207 pub const fn generated_variant(self) -> &'static str {
209 match self {
210 Self::MissingAuthority => "MissingAuthority",
211 Self::Running => "Running",
212 Self::Completed => "Completed",
213 Self::Failed => "Failed",
214 Self::Cancelled => "Cancelled",
215 }
216 }
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
222#[serde(rename_all = "snake_case")]
223pub enum OperationCompletionWakeClass {
224 Wake,
225 Ignore,
226}
227
228impl OperationCompletionWakeClass {
229 pub const ALL: [Self; 2] = [Self::Wake, Self::Ignore];
231
232 pub const fn generated_variant(self) -> &'static str {
234 match self {
235 Self::Wake => "Wake",
236 Self::Ignore => "Ignore",
237 }
238 }
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
243#[serde(rename_all = "snake_case")]
244pub enum OperationLifecycleAction {
245 Start,
246 Fail,
247 PeerReady,
248 ProgressReported,
249 Complete,
250 Abort,
251 Cancel,
252 RetireRequested,
253 RetireCompleted,
254 Terminate,
255}
256
257impl OperationLifecycleAction {
258 pub const ALL: [Self; 10] = [
260 Self::Start,
261 Self::Fail,
262 Self::PeerReady,
263 Self::ProgressReported,
264 Self::Complete,
265 Self::Abort,
266 Self::Cancel,
267 Self::RetireRequested,
268 Self::RetireCompleted,
269 Self::Terminate,
270 ];
271
272 pub const fn generated_variant(self) -> &'static str {
274 match self {
275 Self::Start => "Start",
276 Self::Fail => "Fail",
277 Self::PeerReady => "PeerReady",
278 Self::ProgressReported => "ProgressReported",
279 Self::Complete => "Complete",
280 Self::Abort => "Abort",
281 Self::Cancel => "Cancel",
282 Self::RetireRequested => "RetireRequested",
283 Self::RetireCompleted => "RetireCompleted",
284 Self::Terminate => "Terminate",
285 }
286 }
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
291pub struct OperationLifecycleSnapshot {
292 pub id: OperationId,
293 pub kind: OperationKind,
294 pub owner_session_id: SessionId,
296 pub display_name: String,
297 pub source_label: String,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub operation_source: Option<OperationSource>,
300 pub status: OperationStatus,
301 pub terminal: bool,
303 pub public_result_class: OperationPublicResultClass,
305 pub peer_ready: bool,
306 pub progress_count: u32,
307 pub watcher_count: u32,
308 pub terminal_outcome: Option<OperationTerminalOutcome>,
309 pub child_session_id: Option<SessionId>,
310 pub expect_peer_channel: bool,
311 #[serde(skip_serializing_if = "Option::is_none", default)]
313 pub peer_handle: Option<OperationPeerHandle>,
314 #[serde(default)]
316 pub created_at_ms: u64,
317 #[serde(skip_serializing_if = "Option::is_none", default)]
319 pub started_at_ms: Option<u64>,
320 #[serde(skip_serializing_if = "Option::is_none", default)]
322 pub completed_at_ms: Option<u64>,
323 #[serde(skip_serializing_if = "Option::is_none", default)]
325 pub elapsed_ms: Option<u64>,
326}
327
328pub type OperationCompletionWatch = Pin<
335 Box<
336 dyn Future<Output = Result<OperationTerminalOutcome, OperationCompletionWatchError>>
337 + Send
338 + 'static,
339 >,
340>;
341
342#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
347pub enum OperationCompletionWatchError {
348 #[error("operation completion channel closed without an authorized terminal outcome")]
349 ChannelClosed,
350}
351
352#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
354pub enum OpsLifecycleError {
355 #[error("operation already registered: {0}")]
356 AlreadyRegistered(OperationId),
357 #[error("operation not found: {0}")]
358 NotFound(OperationId),
359 #[error("invalid lifecycle transition for {id}: {status:?} -> {action}")]
360 InvalidTransition {
361 id: OperationId,
362 status: OperationStatus,
363 action: &'static str,
364 },
365 #[error("operation does not expect a peer handoff: {0}")]
366 PeerNotExpected(OperationId),
367 #[error("operation is already peer-ready: {0}")]
368 AlreadyPeerReady(OperationId),
369 #[error("max concurrent operations exceeded (limit: {limit}, active: {active})")]
370 MaxConcurrentExceeded { limit: usize, active: usize },
371 #[error("operation not supported: {0}")]
372 Unsupported(String),
373 #[error("wait_all already active")]
374 WaitAlreadyActive,
375 #[error("wait_all not active for request: {0}")]
376 WaitNotActive(WaitRequestId),
377 #[error("wait_all contains duplicate operation id: {0}")]
378 DuplicateWaitOperation(OperationId),
379 #[error("operation owner is retired")]
380 OwnerRetired,
381 #[error("internal lifecycle registry error: {0}")]
382 Internal(String),
383}
384
385#[derive(Debug)]
392pub struct WaitAllResult {
393 pub outcomes: Vec<(OperationId, OperationTerminalOutcome)>,
395 pub satisfied: WaitAllSatisfied,
397}
398
399#[derive(Debug)]
406pub struct WaitAllSatisfied {
407 pub wait_request_id: WaitRequestId,
409 pub run_id: RunId,
411 pub operation_ids: Vec<OperationId>,
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
417pub enum CompletionCursorConsumer {
418 AgentApplied,
420 RuntimeObserved,
422 RuntimeInjected,
424}
425
426pub trait OpsLifecycleRegistry: Send + Sync {
428 fn register_operation(&self, spec: OperationSpec) -> Result<(), OpsLifecycleError>;
429 fn provisioning_succeeded(&self, id: &OperationId) -> Result<(), OpsLifecycleError>;
430 fn provisioning_failed(&self, id: &OperationId, error: String)
431 -> Result<(), OpsLifecycleError>;
432 fn peer_ready(
433 &self,
434 id: &OperationId,
435 peer: OperationPeerHandle,
436 ) -> Result<(), OpsLifecycleError>;
437 fn register_watcher(
438 &self,
439 id: &OperationId,
440 ) -> Result<OperationCompletionWatch, OpsLifecycleError>;
441 fn report_progress(
442 &self,
443 id: &OperationId,
444 update: OperationProgressUpdate,
445 ) -> Result<(), OpsLifecycleError>;
446 fn complete_operation(
447 &self,
448 id: &OperationId,
449 result: OperationResult,
450 ) -> Result<(), OpsLifecycleError>;
451 fn fail_operation(&self, id: &OperationId, error: String) -> Result<(), OpsLifecycleError>;
452 fn abort_provisioning(
453 &self,
454 id: &OperationId,
455 reason: Option<String>,
456 ) -> Result<(), OpsLifecycleError>;
457 fn rollback_unreturned_operation(&self, _id: &OperationId) -> Result<(), OpsLifecycleError> {
466 Err(OpsLifecycleError::Unsupported(
467 "rollback_unreturned_operation".into(),
468 ))
469 }
470 fn cancel_operation(
471 &self,
472 id: &OperationId,
473 reason: Option<String>,
474 ) -> Result<(), OpsLifecycleError>;
475 fn request_retire(&self, id: &OperationId) -> Result<(), OpsLifecycleError>;
476 fn mark_retired(&self, id: &OperationId) -> Result<(), OpsLifecycleError>;
477 fn snapshot(
478 &self,
479 id: &OperationId,
480 ) -> Result<Option<OperationLifecycleSnapshot>, OpsLifecycleError>;
481 fn list_operations(&self) -> Result<Vec<OperationLifecycleSnapshot>, OpsLifecycleError>;
482 fn terminate_owner(&self, reason: String) -> Result<(), OpsLifecycleError>;
483
484 fn classify_operation_terminality(
487 &self,
488 _id: &OperationId,
489 _status: OperationStatus,
490 ) -> Result<bool, OpsLifecycleError> {
491 Err(OpsLifecycleError::Unsupported(
492 "classify_operation_terminality".into(),
493 ))
494 }
495
496 fn classify_operation_public_result(
499 &self,
500 _id: &OperationId,
501 ) -> Result<OperationPublicResultClass, OpsLifecycleError> {
502 Err(OpsLifecycleError::Unsupported(
503 "classify_operation_public_result".into(),
504 ))
505 }
506
507 fn classify_operation_completion_wake(
510 &self,
511 _id: &OperationId,
512 _kind: OperationKind,
513 ) -> Result<OperationCompletionWakeClass, OpsLifecycleError> {
514 Err(OpsLifecycleError::Unsupported(
515 "classify_operation_completion_wake".into(),
516 ))
517 }
518
519 fn classify_operation_transition_idempotence(
522 &self,
523 _id: &OperationId,
524 _action: OperationLifecycleAction,
525 ) -> Result<bool, OpsLifecycleError> {
526 Err(OpsLifecycleError::Unsupported(
527 "classify_operation_transition_idempotence".into(),
528 ))
529 }
530
531 fn register_operation_with_admission_limit(
534 &self,
535 _spec: OperationSpec,
536 _max_concurrent: Option<usize>,
537 ) -> Result<(), OpsLifecycleError> {
538 Err(OpsLifecycleError::Unsupported(
539 "register_operation_with_admission_limit".into(),
540 ))
541 }
542
543 fn collect_completed(
545 &self,
546 ) -> Result<Vec<(OperationId, OperationTerminalOutcome)>, OpsLifecycleError> {
547 Err(OpsLifecycleError::Unsupported("collect_completed".into()))
548 }
549
550 fn completion_feed(
556 &self,
557 ) -> Option<std::sync::Arc<dyn crate::completion_feed::CompletionFeed>> {
558 None
559 }
560
561 fn completion_cursor(
568 &self,
569 _consumer: CompletionCursorConsumer,
570 ) -> Result<Option<crate::completion_feed::CompletionSeq>, OpsLifecycleError> {
571 Ok(None)
572 }
573
574 fn advance_completion_cursor(
580 &self,
581 _consumer: CompletionCursorConsumer,
582 _cursor: crate::completion_feed::CompletionSeq,
583 _projection: Option<&EpochCursorState>,
584 ) -> Result<crate::completion_feed::CompletionSeq, OpsLifecycleError> {
585 Err(OpsLifecycleError::Unsupported(
586 "advance_completion_cursor".into(),
587 ))
588 }
589
590 fn wait_all(
597 &self,
598 _run_id: &RunId,
599 _ids: &[OperationId],
600 ) -> std::pin::Pin<
601 Box<dyn std::future::Future<Output = Result<WaitAllResult, OpsLifecycleError>> + Send + '_>,
602 > {
603 Box::pin(std::future::ready(Err(OpsLifecycleError::Unsupported(
604 "wait_all".into(),
605 ))))
606 }
607}
608
609#[cfg(test)]
610#[allow(clippy::unwrap_used, clippy::panic)]
611mod tests {
612 use super::*;
613
614 #[test]
615 fn operation_kind_peer_expectation_matches_contract() {
616 assert!(OperationKind::MobMemberChild.expects_peer_channel());
617 assert!(!OperationKind::BackgroundToolOp.expects_peer_channel());
618 assert!(!OperationKind::BackgroundToolCapacitySlot.expects_peer_channel());
619 assert!(!OperationKind::DetachedJobWait.expects_peer_channel());
620 }
621}