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        // v7 (time-ordered): OperationId is an idempotency/dedup key, never a
22        // secret, so a leaked creation-time is harmless and the ordering gives
23        // better index locality wherever these keys are persisted/indexed.
24        Self(Uuid::now_v7())
25    }
26
27    pub fn from_uuid(uuid: Uuid) -> Self {
28        Self(uuid)
29    }
30
31    pub fn as_uuid(&self) -> Uuid {
32        self.0
33    }
34
35    pub fn as_bytes(&self) -> &[u8; 16] {
36        self.0.as_bytes()
37    }
38}
39
40impl Default for OperationId {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl fmt::Display for OperationId {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        self.0.fmt(f)
49    }
50}
51
52#[derive(Debug, thiserror::Error)]
53pub enum OperationIdParseError {
54    #[error("invalid operation id: {0}")]
55    InvalidUuid(#[from] uuid::Error),
56}
57
58impl FromStr for OperationId {
59    type Err = OperationIdParseError;
60
61    fn from_str(s: &str) -> Result<Self, Self::Err> {
62        Ok(Self(Uuid::parse_str(s)?))
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn new_generates_distinct_ids() {
72        let a = OperationId::new();
73        let b = OperationId::new();
74        assert_ne!(a, b);
75    }
76
77    #[test]
78    fn display_round_trips_through_from_str() {
79        let id = OperationId::new();
80        let parsed: OperationId = id.to_string().parse().unwrap();
81        assert_eq!(id, parsed);
82    }
83
84    #[test]
85    fn rejects_garbage() {
86        assert!("not-a-uuid".parse::<OperationId>().is_err());
87    }
88
89    #[test]
90    fn serde_roundtrip() {
91        let id = OperationId::new();
92        let json = serde_json::to_string(&id).unwrap();
93        let back: OperationId = serde_json::from_str(&json).unwrap();
94        assert_eq!(id, back);
95    }
96}