Skip to main content

mur_common/
sync_types.rs

1//! Typed DTOs for cloud sync API (Go server).
2//!
3//! These replace the previous stringly-typed /api/sync/pull, /api/sync/push
4//! endpoints with the current team-scoped, integer-versioned API.
5
6use serde::{Deserialize, Serialize};
7
8/// Response from GET /api/v1/core/teams/{id}/sync/pull?since={v}
9#[derive(Debug, Deserialize)]
10pub struct SyncPullResponse {
11    pub patterns: Vec<RemotePattern>,
12    pub version: i64,
13}
14
15/// A single remote pattern in a pull response.
16#[derive(Debug, Deserialize)]
17pub struct RemotePattern {
18    pub id: String,
19    pub name: String,
20    pub content: String,
21    pub version: i64,
22    #[serde(default)]
23    pub deleted: bool,
24}
25
26/// Request body for POST /api/v1/core/teams/{id}/sync/push
27#[derive(Debug, Serialize)]
28pub struct SyncPushRequest {
29    pub base_version: i64,
30    pub changes: Vec<PatternChange>,
31    #[serde(default)]
32    pub force_local: bool,
33}
34
35/// A single change in a push request.
36#[derive(Debug, Serialize)]
37pub struct PatternChange {
38    pub action: String,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub id: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub pattern: Option<PatternPayload>,
43}
44
45#[derive(Debug, Serialize)]
46pub struct PatternPayload {
47    pub name: String,
48    pub content: String,
49}
50
51/// Response from POST /api/v1/core/teams/{id}/sync/push
52#[derive(Debug, Deserialize)]
53pub struct SyncPushResponse {
54    pub ok: bool,
55    #[serde(default)]
56    pub version: Option<i64>,
57    #[serde(default)]
58    pub conflict: Option<bool>,
59}
60
61/// Response from GET /api/v1/workflows
62#[derive(Debug, Deserialize)]
63pub struct WorkflowListResponse {
64    pub data: Vec<serde_json::Value>,
65}
66
67// ── Fleet sync DTOs (Pro) ──────────────────────────────────────────
68
69/// Kinds of fleet entity synced per-user across devices (Phase 1).
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum FleetEntityType {
73    AgentProfile,
74    ModelBinding,
75    Skill,
76}
77
78impl FleetEntityType {
79    /// URL path segment used in `/api/v1/core/fleet/<segment>`.
80    pub fn path_segment(self) -> &'static str {
81        match self {
82            Self::AgentProfile => "agent_profile",
83            Self::ModelBinding => "model_binding",
84            Self::Skill => "skill",
85        }
86    }
87}
88
89/// One create/update/delete of a fleet entity.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct FleetChange {
92    /// "upsert" | "delete"
93    pub action: String,
94    /// Stable logical id: agent UUIDv7 for profiles, model key for bindings.
95    pub logical_id: String,
96    /// SHA-256 of the canonical payload (empty for deletes).
97    pub content_hash: String,
98    /// Canonical YAML/JSON payload (None for deletes).
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub payload: Option<String>,
101}
102
103/// One entity returned by a fleet pull.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct FleetEntity {
106    pub logical_id: String,
107    pub content_hash: String,
108    pub version: i64,
109    pub deleted: bool,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub payload: Option<String>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct FleetPushRequest {
116    pub base_version: i64,
117    pub entity_type: FleetEntityType,
118    pub changes: Vec<FleetChange>,
119    #[serde(default)]
120    pub force_local: bool,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct FleetPushResponse {
125    pub ok: bool,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub version: Option<i64>,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub conflict: Option<bool>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct FleetPullResponse {
134    pub entities: Vec<FleetEntity>,
135    pub version: i64,
136}
137
138/// Combined fleet payload for one skill directory.
139/// - `manifest_yaml`: raw `skill.yaml` (synced via LWW on `content_sha256`).
140/// - `events_jsonl`: raw `events.jsonl` content (set-union merge on conflict).
141/// - `content_sha256`: sha-256 of `manifest_yaml`.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct SkillFleetPayload {
144    pub manifest_yaml: String,
145    pub events_jsonl: String,
146    pub content_sha256: String,
147}
148
149#[cfg(test)]
150mod fleet_tests {
151    use super::*;
152
153    #[test]
154    fn fleet_push_request_roundtrips() {
155        let req = FleetPushRequest {
156            base_version: 7,
157            entity_type: FleetEntityType::AgentProfile,
158            changes: vec![FleetChange {
159                action: "upsert".into(),
160                logical_id: "agent-abc".into(),
161                content_hash: "deadbeef".into(),
162                payload: Some("name: scout\n".into()),
163            }],
164            force_local: false,
165        };
166        let json = serde_json::to_string(&req).unwrap();
167        let back: FleetPushRequest = serde_json::from_str(&json).unwrap();
168        assert_eq!(back.base_version, 7);
169        assert_eq!(back.entity_type, FleetEntityType::AgentProfile);
170        assert_eq!(back.changes[0].logical_id, "agent-abc");
171    }
172
173    #[test]
174    fn fleet_entity_type_serializes_snake_case() {
175        assert_eq!(
176            serde_json::to_string(&FleetEntityType::ModelBinding).unwrap(),
177            "\"model_binding\""
178        );
179    }
180
181    #[test]
182    fn skill_entity_type_roundtrips() {
183        let s = serde_json::to_string(&FleetEntityType::Skill).unwrap();
184        assert_eq!(s, r#""skill""#);
185        let back: FleetEntityType = serde_json::from_str(&s).unwrap();
186        assert_eq!(back, FleetEntityType::Skill);
187        assert_eq!(FleetEntityType::Skill.path_segment(), "skill");
188    }
189
190    #[test]
191    fn skill_fleet_payload_roundtrips() {
192        let p = SkillFleetPayload {
193            manifest_yaml: "name: foo\n".into(),
194            events_jsonl: "{\"kind\":\"retrieval\"}\n".into(),
195            content_sha256: "abc123".into(),
196        };
197        let s = serde_json::to_string(&p).unwrap();
198        let back: SkillFleetPayload = serde_json::from_str(&s).unwrap();
199        assert_eq!(back.content_sha256, "abc123");
200    }
201}