Skip to main content

meerkat_mob/
ids.rs

1//! Newtype identifiers for mob entities.
2//!
3//! These types wrap concrete primitives for compile-time safety.
4
5use serde::{Deserialize, Serialize};
6use std::borrow::Borrow;
7use std::fmt;
8use std::str::FromStr;
9use uuid::Uuid;
10
11/// Unique identifier for a flow run.
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
13#[serde(transparent)]
14pub struct RunId(Uuid);
15
16impl RunId {
17    pub fn new() -> Self {
18        Self(Uuid::new_v4())
19    }
20
21    pub fn as_uuid(&self) -> &Uuid {
22        &self.0
23    }
24}
25
26impl Default for RunId {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32impl fmt::Display for RunId {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        self.0.fmt(f)
35    }
36}
37
38impl FromStr for RunId {
39    type Err = uuid::Error;
40
41    fn from_str(s: &str) -> Result<Self, Self::Err> {
42        Ok(Self(Uuid::parse_str(s)?))
43    }
44}
45
46macro_rules! string_newtype {
47    ($(#[$meta:meta])* $name:ident) => {
48        $(#[$meta])*
49        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
50        #[serde(transparent)]
51        pub struct $name(String);
52
53        impl $name {
54            pub fn as_str(&self) -> &str {
55                &self.0
56            }
57        }
58
59        impl fmt::Display for $name {
60            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61                self.0.fmt(f)
62            }
63        }
64
65        impl From<String> for $name {
66            fn from(value: String) -> Self {
67                Self(value)
68            }
69        }
70
71        impl From<&str> for $name {
72            fn from(value: &str) -> Self {
73                Self(value.to_owned())
74            }
75        }
76
77        impl Borrow<str> for $name {
78            fn borrow(&self) -> &str {
79                &self.0
80            }
81        }
82
83        impl Borrow<String> for $name {
84            fn borrow(&self) -> &String {
85                &self.0
86            }
87        }
88
89        impl AsRef<str> for $name {
90            fn as_ref(&self) -> &str {
91                &self.0
92            }
93        }
94
95        impl PartialEq<String> for $name {
96            fn eq(&self, other: &String) -> bool {
97                &self.0 == other
98            }
99        }
100
101        impl PartialEq<&String> for $name {
102            fn eq(&self, other: &&String) -> bool {
103                &self.0 == *other
104            }
105        }
106
107        impl PartialEq<str> for $name {
108            fn eq(&self, other: &str) -> bool {
109                self.0.as_str() == other
110            }
111        }
112
113        impl PartialEq<&str> for $name {
114            fn eq(&self, other: &&str) -> bool {
115                self.0.as_str() == *other
116            }
117        }
118    };
119}
120
121string_newtype!(
122    /// Unique identifier for a mob instance.
123    MobId
124);
125
126string_newtype!(
127    /// Unique identifier for a flow definition.
128    FlowId
129);
130
131string_newtype!(
132    /// Unique identifier for a step in a flow definition.
133    StepId
134);
135
136string_newtype!(
137    /// Branch group identifier used by mutually-exclusive flow steps.
138    BranchId
139);
140
141/// Legacy carrier-name alias for [`AgentIdentity`].
142///
143/// DELETE_ME A5 DSL-schema migration: the 0.6 identity-first cascade
144/// unifies the "member identifier string" fact under a single type.
145/// `MeerkatId` was a separate `string_newtype!` wrapper over `String`,
146/// structurally identical to `AgentIdentity` but nominally distinct —
147/// that was parallel truth under dogma principle #1 ("one semantic
148/// fact, one owner").
149///
150/// Collapsing the type (`pub type MeerkatId = AgentIdentity;`) unifies
151/// the ownership without forcing a rename of every generated DSL
152/// command variant field (`Retire { agent_identity }`, `Wire { local }`,
153/// etc.) in a single pass — those field names are now just aliases
154/// that read as `AgentIdentity`. Follow-up passes can rename the
155/// fields to `agent_identity` incrementally without breaking the
156/// type-level invariant.
157///
158/// Existing call sites that used `MeerkatId::from(s)` still work
159/// (forwarded to `AgentIdentity::from(s)`). Existing
160/// `impl From<AgentIdentity> for MeerkatId` / `From<&AgentIdentity>`
161/// impls become reflexive conversions that rustc auto-provides.
162pub type MeerkatId = AgentIdentity;
163
164string_newtype!(
165    /// Profile name within a mob definition.
166    ProfileName
167);
168
169string_newtype!(
170    /// Runtime identifier for a flow execution frame. One per FrameSpec invocation.
171    FrameId
172);
173
174string_newtype!(
175    /// Runtime identifier for one instance of a repeat_until loop.
176    LoopInstanceId
177);
178
179string_newtype!(
180    /// Lexical identifier for a node within a FrameSpec.
181    FlowNodeId
182);
183
184string_newtype!(
185    /// Lexical identifier for a loop definition within a FrameSpec.
186    LoopId
187);
188
189// ---------------------------------------------------------------------------
190// Identity-first mob model types (0.6)
191// ---------------------------------------------------------------------------
192
193string_newtype!(
194    /// Stable, human-meaningful identity for a mob member.
195    ///
196    /// An `AgentIdentity` is assigned at spawn and persists across respawns and
197    /// resets. It is the canonical key for all public mob APIs.
198    AgentIdentity
199);
200
201// DELETE_ME A5 DSL-schema migration: `MeerkatId` is now a type alias
202// for `AgentIdentity` (declared above the `AgentIdentity` definition
203// at the top of the "identity-first" section). The previous explicit
204// `From<MeerkatId> for AgentIdentity` / `From<AgentIdentity> for
205// MeerkatId` bridges become reflexive `impl<T> From<T> for T`
206// (auto-provided by core), so they are no longer defined here.
207// Shell-hot-path borrowed conversion `MeerkatId::from(&identity)` is
208// preserved because `AgentIdentity: From<&AgentIdentity>` is an impl
209// we provide below (via the shared string-newtype macro's `From<&str>`
210// plus `AsRef<str>`).
211impl From<&AgentIdentity> for AgentIdentity {
212    fn from(identity: &AgentIdentity) -> Self {
213        Self::from(identity.as_str())
214    }
215}
216
217/// Monotonically increasing generation counter for a mob member.
218///
219/// Starts at 0 on first spawn, advances on each reset. The generation is
220/// part of [`AgentRuntimeId`] and disambiguates successive incarnations of
221/// the same [`AgentIdentity`].
222#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
223#[serde(transparent)]
224pub struct Generation(u64);
225
226impl Generation {
227    /// The initial generation assigned to a freshly spawned member.
228    pub const INITIAL: Self = Self(0);
229
230    /// Create a generation from a raw value.
231    pub const fn new(value: u64) -> Self {
232        Self(value)
233    }
234
235    /// Return the underlying value.
236    pub const fn get(self) -> u64 {
237        self.0
238    }
239
240    /// Advance to the next generation.
241    pub const fn next(self) -> Self {
242        Self(self.0 + 1)
243    }
244}
245
246impl fmt::Display for Generation {
247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248        self.0.fmt(f)
249    }
250}
251
252/// Unique runtime identity for a specific incarnation of a mob member.
253///
254/// Combines the stable [`AgentIdentity`] with a [`Generation`] counter that
255/// advances on reset. Two `AgentRuntimeId` values with the same identity but
256/// different generations represent successive incarnations of the same member.
257#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
258pub struct AgentRuntimeId {
259    /// Stable member identity.
260    pub identity: AgentIdentity,
261    /// Generation counter for this incarnation.
262    pub generation: Generation,
263}
264
265impl AgentRuntimeId {
266    /// Create a new runtime id.
267    pub fn new(identity: AgentIdentity, generation: Generation) -> Self {
268        Self {
269            identity,
270            generation,
271        }
272    }
273
274    /// Create an initial runtime id (generation 0).
275    pub fn initial(identity: AgentIdentity) -> Self {
276        Self {
277            identity,
278            generation: Generation::INITIAL,
279        }
280    }
281}
282
283impl fmt::Display for AgentRuntimeId {
284    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285        write!(f, "{}:{}", self.identity, self.generation.get())
286    }
287}
288
289/// Opaque fence token used to reject stale commands.
290///
291/// A new `FenceToken` is issued at spawn, respawn, and reset. Commands
292/// carrying a stale token are rejected, preventing races where a delayed
293/// message targets an incarnation that has already been replaced.
294#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
295#[serde(transparent)]
296pub struct FenceToken(u64);
297
298impl FenceToken {
299    /// Create a fence token from a raw value.
300    pub const fn new(value: u64) -> Self {
301        Self(value)
302    }
303
304    /// Return the underlying value.
305    pub const fn get(self) -> u64 {
306        self.0
307    }
308}
309
310impl fmt::Display for FenceToken {
311    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
312        write!(f, "fence:{}", self.0)
313    }
314}
315
316/// Unique identifier for a unit of work submitted to a mob member.
317///
318/// Analogous to [`RunId`] but scoped to the work-lane abstraction introduced
319/// in the identity-first model.
320#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
321#[serde(transparent)]
322pub struct WorkRef(Uuid);
323
324impl WorkRef {
325    /// Generate a new random work reference.
326    pub fn new() -> Self {
327        Self(Uuid::new_v4())
328    }
329
330    /// Return the underlying UUID.
331    pub fn as_uuid(&self) -> &Uuid {
332        &self.0
333    }
334}
335
336impl Default for WorkRef {
337    fn default() -> Self {
338        Self::new()
339    }
340}
341
342impl fmt::Display for WorkRef {
343    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344        self.0.fmt(f)
345    }
346}
347
348impl FromStr for WorkRef {
349    type Err = uuid::Error;
350
351    fn from_str(s: &str) -> Result<Self, Self::Err> {
352        Ok(Self(Uuid::parse_str(s)?))
353    }
354}
355
356/// Describes a unit of work to be executed by a mob member.
357///
358/// `WorkSpec` is submitted alongside a [`WorkRef`] and [`FenceToken`] through
359/// the work lane. It captures the content and delivery semantics without
360/// exposing session-level details.
361///
362/// DELETE_ME C6: `content` is a full [`meerkat_core::types::ContentInput`]
363/// (multimodal) rather than `String`, matching the rest of the platform's
364/// content-carrying types. Prior to this change the work lane was silently
365/// text-only, which was a capability regression vs. every other member-
366/// delivery surface. `impl From<String> for ContentInput` / `From<&str>` in
367/// `meerkat_core` means existing String call sites upgrade without
368/// per-call-site conversion noise.
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct WorkSpec {
371    /// The content to deliver to the member.
372    pub content: meerkat_core::types::ContentInput,
373    /// Whether this is an externally-originated turn (user input) or an
374    /// internally-originated turn (mob coordination).
375    pub origin: WorkOrigin,
376}
377
378impl WorkSpec {
379    /// Create a new work spec. Accepts anything that implements
380    /// `Into<ContentInput>` — including `String` and `&str` — so existing
381    /// text-only call sites upgrade without churn.
382    pub fn new(content: impl Into<meerkat_core::types::ContentInput>, origin: WorkOrigin) -> Self {
383        Self {
384            content: content.into(),
385            origin,
386        }
387    }
388}
389
390/// Origin classification for a [`WorkSpec`].
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
392pub enum WorkOrigin {
393    /// Externally-originated work (user or API surface).
394    External,
395    /// Internally-originated work (mob orchestration, flow engine).
396    Internal,
397}
398
399impl WorkOrigin {
400    /// Stable string label consumed by `MobMachine` DSL guards.
401    pub const fn as_str(self) -> &'static str {
402        match self {
403            WorkOrigin::External => "External",
404            WorkOrigin::Internal => "Internal",
405        }
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn test_run_id_roundtrip_json() {
415        let run_id = RunId::new();
416        let encoded = serde_json::to_string(&run_id).unwrap();
417        let decoded: RunId = serde_json::from_str(&encoded).unwrap();
418        assert_eq!(decoded, run_id);
419    }
420
421    #[test]
422    fn test_run_id_roundtrip_parse_display() {
423        let run_id = RunId::new();
424        let rendered = run_id.to_string();
425        let reparsed = RunId::from_str(&rendered).unwrap();
426        assert_eq!(reparsed, run_id);
427    }
428
429    #[test]
430    fn test_flow_id_roundtrip_json() {
431        let id = FlowId::from("flow-a");
432        let encoded = serde_json::to_string(&id).unwrap();
433        let decoded: FlowId = serde_json::from_str(&encoded).unwrap();
434        assert_eq!(decoded, id);
435    }
436
437    #[test]
438    fn test_step_id_roundtrip_json() {
439        let id = StepId::from("step-a");
440        let encoded = serde_json::to_string(&id).unwrap();
441        let decoded: StepId = serde_json::from_str(&encoded).unwrap();
442        assert_eq!(decoded, id);
443    }
444
445    #[test]
446    fn test_branch_id_roundtrip_json() {
447        let id = BranchId::from("branch-a");
448        let encoded = serde_json::to_string(&id).unwrap();
449        let decoded: BranchId = serde_json::from_str(&encoded).unwrap();
450        assert_eq!(decoded, id);
451    }
452
453    #[test]
454    fn test_frame_id_roundtrip_json() {
455        let id = FrameId::from("frame-a");
456        let encoded = serde_json::to_string(&id).unwrap();
457        let decoded: FrameId = serde_json::from_str(&encoded).unwrap();
458        assert_eq!(decoded, id);
459    }
460
461    #[test]
462    fn test_loop_instance_id_roundtrip_json() {
463        let id = LoopInstanceId::from("loop-instance-a");
464        let encoded = serde_json::to_string(&id).unwrap();
465        let decoded: LoopInstanceId = serde_json::from_str(&encoded).unwrap();
466        assert_eq!(decoded, id);
467    }
468
469    #[test]
470    fn test_flow_node_id_roundtrip_json() {
471        let id = FlowNodeId::from("node-a");
472        let encoded = serde_json::to_string(&id).unwrap();
473        let decoded: FlowNodeId = serde_json::from_str(&encoded).unwrap();
474        assert_eq!(decoded, id);
475    }
476
477    #[test]
478    fn test_loop_id_roundtrip_json() {
479        let id = LoopId::from("loop-a");
480        let encoded = serde_json::to_string(&id).unwrap();
481        let decoded: LoopId = serde_json::from_str(&encoded).unwrap();
482        assert_eq!(decoded, id);
483    }
484
485    /// DELETE_ME A5 regression: identity-first hot paths and the
486    /// DSL-schema migration unify under a single type.
487    ///
488    /// Originally `MeerkatId` and `AgentIdentity` were two distinct
489    /// `string_newtype!` wrappers, and this test pinned that the
490    /// shell conversion between them preserved the underlying string
491    /// without semantic change. Post-A5-DSL-migration `MeerkatId` is
492    /// a type alias for `AgentIdentity`, so "conversion" is now a
493    /// no-op at the type level — there is only one owner of the
494    /// member-identifier-string fact. The test stays to pin the
495    /// invariant that `MeerkatId::from("…").as_str()` round-trips to
496    /// the expected string on both the owned and borrowed shell-hot
497    /// paths (`MeerkatId::from(&identity)`) and that the two names
498    /// continue to refer to the same value identity.
499    #[test]
500    fn agent_identity_to_meerkat_id_conversion_preserves_identity_string() {
501        let identity = AgentIdentity::from("singer");
502
503        // Owned conversion (now a type-level no-op).
504        let by_owned: MeerkatId = identity.clone();
505        assert_eq!(by_owned.as_str(), "singer");
506
507        // Borrowed conversion — the hot-path shape used by
508        // `MobHandle::wire`, `internal_turn`, `realtime_attach`, etc.
509        let by_borrow: MeerkatId = (&identity).into();
510        assert_eq!(by_borrow.as_str(), "singer");
511
512        // Round trip: MeerkatId and AgentIdentity are the same type
513        // post-A5-DSL-migration, so equality compares the shared
514        // newtype value.
515        let back: AgentIdentity = by_owned;
516        assert_eq!(back, identity);
517    }
518
519    #[test]
520    fn test_existing_ids_roundtrip() {
521        let mob = MobId::from("mob-a");
522        let meerkat = MeerkatId::from("meerkat-a");
523        let profile = ProfileName::from("lead");
524        assert_eq!(
525            serde_json::from_str::<MobId>(&serde_json::to_string(&mob).unwrap()).unwrap(),
526            mob
527        );
528        assert_eq!(
529            serde_json::from_str::<MeerkatId>(&serde_json::to_string(&meerkat).unwrap()).unwrap(),
530            meerkat
531        );
532        assert_eq!(
533            serde_json::from_str::<ProfileName>(&serde_json::to_string(&profile).unwrap()).unwrap(),
534            profile
535        );
536    }
537
538    // --- Identity-first model types ---
539
540    #[test]
541    fn test_agent_identity_roundtrip_json() {
542        let id = AgentIdentity::from("researcher");
543        let encoded = serde_json::to_string(&id).unwrap();
544        assert_eq!(encoded, "\"researcher\"");
545        let decoded: AgentIdentity = serde_json::from_str(&encoded).unwrap();
546        assert_eq!(decoded, id);
547    }
548
549    #[test]
550    fn test_agent_identity_display() {
551        let id = AgentIdentity::from("lead-agent");
552        assert_eq!(id.to_string(), "lead-agent");
553        assert_eq!(id.as_str(), "lead-agent");
554    }
555
556    #[test]
557    fn test_generation_roundtrip_json() {
558        let generation = Generation::new(42);
559        let encoded = serde_json::to_string(&generation).unwrap();
560        assert_eq!(encoded, "42");
561        let decoded: Generation = serde_json::from_str(&encoded).unwrap();
562        assert_eq!(decoded, generation);
563    }
564
565    #[test]
566    fn test_generation_initial_and_next() {
567        assert_eq!(Generation::INITIAL.get(), 0);
568        assert_eq!(Generation::INITIAL.next().get(), 1);
569        assert_eq!(Generation::new(5).next().get(), 6);
570    }
571
572    #[test]
573    fn test_generation_ordering() {
574        assert!(Generation::new(0) < Generation::new(1));
575        assert!(Generation::new(1) < Generation::new(100));
576    }
577
578    #[test]
579    fn test_agent_runtime_id_roundtrip_json() {
580        let rid = AgentRuntimeId::new(AgentIdentity::from("worker"), Generation::new(3));
581        let encoded = serde_json::to_string(&rid).unwrap();
582        let decoded: AgentRuntimeId = serde_json::from_str(&encoded).unwrap();
583        assert_eq!(decoded, rid);
584    }
585
586    #[test]
587    fn test_agent_runtime_id_initial() {
588        let rid = AgentRuntimeId::initial(AgentIdentity::from("worker"));
589        assert_eq!(rid.identity, AgentIdentity::from("worker"));
590        assert_eq!(rid.generation, Generation::INITIAL);
591    }
592
593    #[test]
594    fn test_agent_runtime_id_display() {
595        let rid = AgentRuntimeId::new(AgentIdentity::from("coder"), Generation::new(2));
596        assert_eq!(rid.to_string(), "coder:2");
597    }
598
599    #[test]
600    fn test_fence_token_roundtrip_json() {
601        let ft = FenceToken::new(99);
602        let encoded = serde_json::to_string(&ft).unwrap();
603        assert_eq!(encoded, "99");
604        let decoded: FenceToken = serde_json::from_str(&encoded).unwrap();
605        assert_eq!(decoded, ft);
606    }
607
608    #[test]
609    fn test_fence_token_display() {
610        assert_eq!(FenceToken::new(7).to_string(), "fence:7");
611    }
612
613    #[test]
614    fn test_fence_token_ordering() {
615        assert!(FenceToken::new(1) < FenceToken::new(2));
616    }
617
618    #[test]
619    fn test_work_ref_roundtrip_json() {
620        let wr = WorkRef::new();
621        let encoded = serde_json::to_string(&wr).unwrap();
622        let decoded: WorkRef = serde_json::from_str(&encoded).unwrap();
623        assert_eq!(decoded, wr);
624    }
625
626    #[test]
627    fn test_work_ref_roundtrip_parse_display() {
628        let wr = WorkRef::new();
629        let rendered = wr.to_string();
630        let reparsed = WorkRef::from_str(&rendered).unwrap();
631        assert_eq!(reparsed, wr);
632    }
633
634    #[test]
635    fn test_work_spec_roundtrip_json() {
636        let spec = WorkSpec::new("do something".to_owned(), WorkOrigin::External);
637        let encoded = serde_json::to_string(&spec).unwrap();
638        let decoded: WorkSpec = serde_json::from_str(&encoded).unwrap();
639        assert_eq!(
640            decoded.content,
641            meerkat_core::types::ContentInput::from("do something".to_string()),
642        );
643        assert_eq!(decoded.origin, WorkOrigin::External);
644    }
645
646    #[test]
647    fn test_work_origin_variants_roundtrip_json() {
648        for origin in [WorkOrigin::External, WorkOrigin::Internal] {
649            let encoded = serde_json::to_string(&origin).unwrap();
650            let decoded: WorkOrigin = serde_json::from_str(&encoded).unwrap();
651            assert_eq!(decoded, origin);
652        }
653    }
654
655    #[test]
656    fn test_work_spec_internal_origin() {
657        let spec = WorkSpec::new("coordinate".to_owned(), WorkOrigin::Internal);
658        assert_eq!(spec.origin, WorkOrigin::Internal);
659        assert_eq!(
660            spec.content,
661            meerkat_core::types::ContentInput::from("coordinate".to_string()),
662        );
663    }
664
665    #[test]
666    fn test_work_spec_accepts_multimodal_content() {
667        // DELETE_ME C6 regression: WorkSpec.content must be ContentInput
668        // (multimodal), not String. This test locks in that non-text
669        // ContentInput variants (e.g. image blocks) can be submitted as
670        // work content without string-coercing them first.
671        let image_block = meerkat_core::types::ContentBlock::Image {
672            media_type: "image/png".to_string(),
673            data: meerkat_core::ImageData::Inline {
674                data: "iVBORw0KGgo=".to_string(),
675            },
676        };
677        let content = meerkat_core::types::ContentInput::Blocks(vec![
678            meerkat_core::types::ContentBlock::Text {
679                text: "analyse this".to_string(),
680            },
681            image_block.clone(),
682        ]);
683        let spec = WorkSpec::new(content.clone(), WorkOrigin::External);
684        assert_eq!(spec.content, content);
685    }
686}