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/// - `stats_json`: serialised `SkillStats` snapshot (server uses for run-history
143///   aggregation without re-parsing the full event log). Optional for backward
144///   compatibility with older clients that do not include it.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct SkillFleetPayload {
147    pub manifest_yaml: String,
148    pub events_jsonl: String,
149    pub content_sha256: String,
150    #[serde(default, skip_serializing_if = "String::is_empty")]
151    pub stats_json: String,
152}
153
154#[cfg(test)]
155mod fleet_tests {
156    use super::*;
157
158    #[test]
159    fn fleet_push_request_roundtrips() {
160        let req = FleetPushRequest {
161            base_version: 7,
162            entity_type: FleetEntityType::AgentProfile,
163            changes: vec![FleetChange {
164                action: "upsert".into(),
165                logical_id: "agent-abc".into(),
166                content_hash: "deadbeef".into(),
167                payload: Some("name: scout\n".into()),
168            }],
169            force_local: false,
170        };
171        let json = serde_json::to_string(&req).unwrap();
172        let back: FleetPushRequest = serde_json::from_str(&json).unwrap();
173        assert_eq!(back.base_version, 7);
174        assert_eq!(back.entity_type, FleetEntityType::AgentProfile);
175        assert_eq!(back.changes[0].logical_id, "agent-abc");
176    }
177
178    #[test]
179    fn fleet_entity_type_serializes_snake_case() {
180        assert_eq!(
181            serde_json::to_string(&FleetEntityType::ModelBinding).unwrap(),
182            "\"model_binding\""
183        );
184    }
185
186    #[test]
187    fn skill_entity_type_roundtrips() {
188        let s = serde_json::to_string(&FleetEntityType::Skill).unwrap();
189        assert_eq!(s, r#""skill""#);
190        let back: FleetEntityType = serde_json::from_str(&s).unwrap();
191        assert_eq!(back, FleetEntityType::Skill);
192        assert_eq!(FleetEntityType::Skill.path_segment(), "skill");
193    }
194
195    #[test]
196    fn skill_fleet_payload_roundtrips() {
197        let p = SkillFleetPayload {
198            manifest_yaml: "name: foo\n".into(),
199            events_jsonl: "{\"kind\":\"retrieval\"}\n".into(),
200            content_sha256: "abc123".into(),
201            stats_json: String::new(),
202        };
203        let s = serde_json::to_string(&p).unwrap();
204        let back: SkillFleetPayload = serde_json::from_str(&s).unwrap();
205        assert_eq!(back.content_sha256, "abc123");
206    }
207}