Skip to main content

objects/object/
operation_id.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Client-supplied operation identifiers for idempotent state-changing calls.
3//!
4//! Every state-changing CLI verb and gRPC method accepts an [`OperationId`].
5//! Repeated calls with the same id return the original outcome rather than
6//! re-executing — the property the agent loop depends on for safe retry.
7//! The newtype keeps the dedup intent visible at every callsite instead of
8//! letting a bare `Uuid` blend in with other identifiers.
9
10use std::{fmt, str::FromStr};
11
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(transparent)]
17pub struct OperationId(pub Uuid);
18
19impl OperationId {
20    pub fn new() -> Self {
21        Self(Uuid::new_v4())
22    }
23
24    pub fn from_uuid(uuid: Uuid) -> Self {
25        Self(uuid)
26    }
27
28    pub fn as_uuid(&self) -> Uuid {
29        self.0
30    }
31
32    pub fn as_bytes(&self) -> &[u8; 16] {
33        self.0.as_bytes()
34    }
35}
36
37impl Default for OperationId {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl fmt::Display for OperationId {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        self.0.fmt(f)
46    }
47}
48
49#[derive(Debug, thiserror::Error)]
50pub enum OperationIdParseError {
51    #[error("invalid operation id: {0}")]
52    InvalidUuid(#[from] uuid::Error),
53}
54
55impl FromStr for OperationId {
56    type Err = OperationIdParseError;
57
58    fn from_str(s: &str) -> Result<Self, Self::Err> {
59        Ok(Self(Uuid::parse_str(s)?))
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    #[test]
68    fn new_generates_distinct_ids() {
69        let a = OperationId::new();
70        let b = OperationId::new();
71        assert_ne!(a, b);
72    }
73
74    #[test]
75    fn display_round_trips_through_from_str() {
76        let id = OperationId::new();
77        let parsed: OperationId = id.to_string().parse().unwrap();
78        assert_eq!(id, parsed);
79    }
80
81    #[test]
82    fn rejects_garbage() {
83        assert!("not-a-uuid".parse::<OperationId>().is_err());
84    }
85
86    #[test]
87    fn serde_roundtrip() {
88        let id = OperationId::new();
89        let json = serde_json::to_string(&id).unwrap();
90        let back: OperationId = serde_json::from_str(&json).unwrap();
91        assert_eq!(id, back);
92    }
93}