Skip to main content

systemprompt_sync/
lib.rs

1//! Cloud sync orchestration for systemprompt.io.
2//!
3//! Drives push/pull of files, agents, skills, content, and database state
4//! between a local systemprompt project and the systemprompt cloud (or a
5//! self-hosted tenant in direct-sync mode).
6//!
7//! # Public surface
8//!
9//! - [`SyncService`], [`SyncConfig`], [`SyncConfigBuilder`] — high-level façade
10//!   that wires everything together for `cloud sync` commands.
11//! - [`SyncApiClient`] — low-level HTTP client for the cloud API.
12//! - [`AgentsLocalSync`], [`SkillsLocalSync`], [`ContentLocalSync`] — disk ↔
13//!   database sync for each domain.
14//! - [`AgentsDiffCalculator`], [`SkillsDiffCalculator`],
15//!   [`ContentDiffCalculator`] — pure diff computation.
16//! - [`SyncError`] / [`SyncResult`] — typed error returned by every public
17//!   function in this crate.
18//!
19//! # Feature flags
20//!
21//! This crate has no Cargo features.
22//!
23//! All public items are doc-commented; module-level `//!` docs explain
24//! responsibility boundaries.
25
26pub mod api_client;
27pub mod crate_deploy;
28pub mod database;
29pub mod diff;
30pub mod error;
31pub mod export;
32mod file_bundler;
33pub mod files;
34pub mod jobs;
35pub mod local;
36pub mod models;
37
38use serde::{Deserialize, Serialize};
39use systemprompt_identifiers::TenantId;
40
41pub use api_client::SyncApiClient;
42pub use database::{ContextExport, DatabaseExport, DatabaseSyncService, SkillExport};
43pub use diff::{
44    AgentsDiffCalculator, ContentDiffCalculator, SkillsDiffCalculator, compute_content_hash,
45};
46pub use error::{SyncError, SyncResult};
47pub use export::{
48    escape_yaml, export_agent_to_disk, export_content_to_file, export_skill_to_disk,
49    generate_agent_config, generate_agent_system_prompt, generate_content_markdown,
50    generate_skill_config, generate_skill_markdown,
51};
52pub use files::{
53    FileBundle, FileDiffStatus, FileEntry, FileManifest, FileSyncService, PullDownload,
54    SyncDiffEntry, SyncDiffResult,
55};
56pub use jobs::{AccessControlSyncJob, ContentSyncJob};
57pub use local::{
58    AccessControlLocalSync, AgentsLocalSync, ContentDiffEntry, ContentLocalSync, SkillsLocalSync,
59};
60pub use models::{
61    AgentDiffItem, AgentsDiffResult, ContentDiffItem, ContentDiffResult, DiffStatus, DiskAgent,
62    DiskContent, DiskSkill, LocalSyncDirection, LocalSyncResult, SkillDiffItem, SkillsDiffResult,
63};
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
66pub enum SyncDirection {
67    Push,
68    Pull,
69}
70
71#[derive(Clone, Debug)]
72pub struct SyncConfig {
73    pub direction: SyncDirection,
74    pub dry_run: bool,
75    pub verbose: bool,
76    pub tenant_id: TenantId,
77    pub api_url: String,
78    pub api_token: String,
79    pub services_path: String,
80    pub hostname: Option<String>,
81    pub sync_token: Option<String>,
82    pub local_database_url: Option<String>,
83}
84
85#[derive(Debug)]
86pub struct SyncConfigBuilder {
87    direction: SyncDirection,
88    dry_run: bool,
89    verbose: bool,
90    tenant_id: TenantId,
91    api_url: String,
92    api_token: String,
93    services_path: String,
94    hostname: Option<String>,
95    sync_token: Option<String>,
96    local_database_url: Option<String>,
97}
98
99impl SyncConfigBuilder {
100    pub fn new(
101        tenant_id: impl Into<TenantId>,
102        api_url: impl Into<String>,
103        api_token: impl Into<String>,
104        services_path: impl Into<String>,
105    ) -> Self {
106        Self {
107            direction: SyncDirection::Push,
108            dry_run: false,
109            verbose: false,
110            tenant_id: tenant_id.into(),
111            api_url: api_url.into(),
112            api_token: api_token.into(),
113            services_path: services_path.into(),
114            hostname: None,
115            sync_token: None,
116            local_database_url: None,
117        }
118    }
119
120    pub const fn with_direction(mut self, direction: SyncDirection) -> Self {
121        self.direction = direction;
122        self
123    }
124
125    pub const fn with_dry_run(mut self, dry_run: bool) -> Self {
126        self.dry_run = dry_run;
127        self
128    }
129
130    pub const fn with_verbose(mut self, verbose: bool) -> Self {
131        self.verbose = verbose;
132        self
133    }
134
135    pub fn with_hostname(mut self, hostname: Option<String>) -> Self {
136        self.hostname = hostname;
137        self
138    }
139
140    pub fn with_sync_token(mut self, sync_token: Option<String>) -> Self {
141        self.sync_token = sync_token;
142        self
143    }
144
145    pub fn with_local_database_url(mut self, url: impl Into<String>) -> Self {
146        self.local_database_url = Some(url.into());
147        self
148    }
149
150    pub fn build(self) -> SyncConfig {
151        SyncConfig {
152            direction: self.direction,
153            dry_run: self.dry_run,
154            verbose: self.verbose,
155            tenant_id: self.tenant_id,
156            api_url: self.api_url,
157            api_token: self.api_token,
158            services_path: self.services_path,
159            hostname: self.hostname,
160            sync_token: self.sync_token,
161            local_database_url: self.local_database_url,
162        }
163    }
164}
165
166impl SyncConfig {
167    pub fn builder(
168        tenant_id: impl Into<TenantId>,
169        api_url: impl Into<String>,
170        api_token: impl Into<String>,
171        services_path: impl Into<String>,
172    ) -> SyncConfigBuilder {
173        SyncConfigBuilder::new(tenant_id, api_url, api_token, services_path)
174    }
175}
176
177#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
178#[serde(tag = "kind", rename_all = "snake_case")]
179pub enum SyncOpState {
180    NotStarted,
181    Partial {
182        completed: usize,
183        total: usize,
184    },
185    #[default]
186    Completed,
187    Failed,
188}
189
190#[derive(Clone, Debug, Serialize, Deserialize)]
191pub struct SyncOperationResult {
192    pub operation: String,
193    pub success: bool,
194    pub items_synced: usize,
195    pub items_skipped: usize,
196    pub errors: Vec<String>,
197    pub details: Option<serde_json::Value>,
198    #[serde(default)]
199    pub state: SyncOpState,
200}
201
202impl SyncOperationResult {
203    pub fn success(operation: &str, items_synced: usize) -> Self {
204        Self {
205            operation: operation.to_string(),
206            success: true,
207            items_synced,
208            items_skipped: 0,
209            errors: vec![],
210            details: None,
211            state: SyncOpState::Completed,
212        }
213    }
214
215    pub fn with_details(mut self, details: serde_json::Value) -> Self {
216        self.details = Some(details);
217        self
218    }
219
220    pub fn dry_run(operation: &str, items_skipped: usize, details: serde_json::Value) -> Self {
221        Self {
222            operation: operation.to_string(),
223            success: true,
224            items_synced: 0,
225            items_skipped,
226            errors: vec![],
227            details: Some(details),
228            state: SyncOpState::Completed,
229        }
230    }
231}
232
233#[derive(Debug)]
234pub struct SyncService {
235    config: SyncConfig,
236    api_client: SyncApiClient,
237}
238
239impl SyncService {
240    pub fn new(config: SyncConfig) -> SyncResult<Self> {
241        let api_client = SyncApiClient::new(&config.api_url, &config.api_token)?
242            .with_direct_sync(config.hostname.clone(), config.sync_token.clone());
243        Ok(Self { config, api_client })
244    }
245
246    pub async fn sync_files(&self) -> SyncResult<SyncOperationResult> {
247        let service = FileSyncService::new(self.config.clone(), self.api_client.clone());
248        service.sync().await
249    }
250
251    pub async fn sync_database(&self) -> SyncResult<SyncOperationResult> {
252        let local_db_url = self.config.local_database_url.as_ref().ok_or_else(|| {
253            SyncError::MissingConfig("local_database_url not configured".to_string())
254        })?;
255
256        let cloud_db_url = self
257            .api_client
258            .get_database_url(&self.config.tenant_id)
259            .await
260            .map_err(|e| SyncError::ApiError {
261                status: 500,
262                message: format!("Failed to get cloud database URL: {e}"),
263            })?;
264
265        let service = DatabaseSyncService::new(
266            self.config.direction,
267            self.config.dry_run,
268            local_db_url,
269            &cloud_db_url,
270        );
271
272        service.sync().await
273    }
274
275    pub async fn sync_all(&self) -> SyncResult<Vec<SyncOperationResult>> {
276        let mut results = Vec::new();
277
278        let files_result = self.sync_files().await?;
279        results.push(files_result);
280
281        match self.sync_database().await {
282            Ok(db_result) => results.push(db_result),
283            Err(e) => {
284                tracing::warn!(error = %e, "Database sync failed");
285                let (state, items_synced) = match &e {
286                    SyncError::MissingConfig(_) => (SyncOpState::NotStarted, 0),
287                    SyncError::PartialImport {
288                        completed, total, ..
289                    } => (
290                        SyncOpState::Partial {
291                            completed: *completed,
292                            total: *total,
293                        },
294                        *completed,
295                    ),
296                    _ => (SyncOpState::Failed, 0),
297                };
298                results.push(SyncOperationResult {
299                    operation: "database".to_string(),
300                    success: false,
301                    items_synced,
302                    items_skipped: 0,
303                    errors: vec![e.to_string()],
304                    details: None,
305                    state,
306                });
307            },
308        }
309
310        Ok(results)
311    }
312}