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::ContentSyncJob;
57pub use local::{AgentsLocalSync, ContentDiffEntry, ContentLocalSync, SkillsLocalSync};
58pub use models::{
59    AgentDiffItem, AgentsDiffResult, ContentDiffItem, ContentDiffResult, DiffStatus, DiskAgent,
60    DiskContent, DiskSkill, LocalSyncDirection, LocalSyncResult, SkillDiffItem, SkillsDiffResult,
61};
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
64pub enum SyncDirection {
65    Push,
66    Pull,
67}
68
69#[derive(Clone, Debug)]
70pub struct SyncConfig {
71    pub direction: SyncDirection,
72    pub dry_run: bool,
73    pub verbose: bool,
74    pub tenant_id: TenantId,
75    pub api_url: String,
76    pub api_token: String,
77    pub services_path: String,
78    pub hostname: Option<String>,
79    pub sync_token: Option<String>,
80    pub local_database_url: Option<String>,
81}
82
83#[derive(Debug)]
84pub struct SyncConfigBuilder {
85    direction: SyncDirection,
86    dry_run: bool,
87    verbose: bool,
88    tenant_id: TenantId,
89    api_url: String,
90    api_token: String,
91    services_path: String,
92    hostname: Option<String>,
93    sync_token: Option<String>,
94    local_database_url: Option<String>,
95}
96
97impl SyncConfigBuilder {
98    pub fn new(
99        tenant_id: impl Into<TenantId>,
100        api_url: impl Into<String>,
101        api_token: impl Into<String>,
102        services_path: impl Into<String>,
103    ) -> Self {
104        Self {
105            direction: SyncDirection::Push,
106            dry_run: false,
107            verbose: false,
108            tenant_id: tenant_id.into(),
109            api_url: api_url.into(),
110            api_token: api_token.into(),
111            services_path: services_path.into(),
112            hostname: None,
113            sync_token: None,
114            local_database_url: None,
115        }
116    }
117
118    pub const fn with_direction(mut self, direction: SyncDirection) -> Self {
119        self.direction = direction;
120        self
121    }
122
123    pub const fn with_dry_run(mut self, dry_run: bool) -> Self {
124        self.dry_run = dry_run;
125        self
126    }
127
128    pub const fn with_verbose(mut self, verbose: bool) -> Self {
129        self.verbose = verbose;
130        self
131    }
132
133    pub fn with_hostname(mut self, hostname: Option<String>) -> Self {
134        self.hostname = hostname;
135        self
136    }
137
138    pub fn with_sync_token(mut self, sync_token: Option<String>) -> Self {
139        self.sync_token = sync_token;
140        self
141    }
142
143    pub fn with_local_database_url(mut self, url: impl Into<String>) -> Self {
144        self.local_database_url = Some(url.into());
145        self
146    }
147
148    pub fn build(self) -> SyncConfig {
149        SyncConfig {
150            direction: self.direction,
151            dry_run: self.dry_run,
152            verbose: self.verbose,
153            tenant_id: self.tenant_id,
154            api_url: self.api_url,
155            api_token: self.api_token,
156            services_path: self.services_path,
157            hostname: self.hostname,
158            sync_token: self.sync_token,
159            local_database_url: self.local_database_url,
160        }
161    }
162}
163
164impl SyncConfig {
165    pub fn builder(
166        tenant_id: impl Into<TenantId>,
167        api_url: impl Into<String>,
168        api_token: impl Into<String>,
169        services_path: impl Into<String>,
170    ) -> SyncConfigBuilder {
171        SyncConfigBuilder::new(tenant_id, api_url, api_token, services_path)
172    }
173}
174
175#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
176#[serde(tag = "kind", rename_all = "snake_case")]
177pub enum SyncOpState {
178    NotStarted,
179    Partial {
180        completed: usize,
181        total: usize,
182    },
183    #[default]
184    Completed,
185    Failed,
186}
187
188#[derive(Clone, Debug, Serialize, Deserialize)]
189pub struct SyncOperationResult {
190    pub operation: String,
191    pub success: bool,
192    pub items_synced: usize,
193    pub items_skipped: usize,
194    pub errors: Vec<String>,
195    pub details: Option<serde_json::Value>,
196    #[serde(default)]
197    pub state: SyncOpState,
198}
199
200impl SyncOperationResult {
201    pub fn success(operation: &str, items_synced: usize) -> Self {
202        Self {
203            operation: operation.to_string(),
204            success: true,
205            items_synced,
206            items_skipped: 0,
207            errors: vec![],
208            details: None,
209            state: SyncOpState::Completed,
210        }
211    }
212
213    pub fn with_details(mut self, details: serde_json::Value) -> Self {
214        self.details = Some(details);
215        self
216    }
217
218    pub fn dry_run(operation: &str, items_skipped: usize, details: serde_json::Value) -> Self {
219        Self {
220            operation: operation.to_string(),
221            success: true,
222            items_synced: 0,
223            items_skipped,
224            errors: vec![],
225            details: Some(details),
226            state: SyncOpState::Completed,
227        }
228    }
229}
230
231#[derive(Debug)]
232pub struct SyncService {
233    config: SyncConfig,
234    api_client: SyncApiClient,
235}
236
237impl SyncService {
238    pub fn new(config: SyncConfig) -> SyncResult<Self> {
239        let api_client = SyncApiClient::new(&config.api_url, &config.api_token)?
240            .with_direct_sync(config.hostname.clone(), config.sync_token.clone());
241        Ok(Self { config, api_client })
242    }
243
244    pub async fn sync_files(&self) -> SyncResult<SyncOperationResult> {
245        let service = FileSyncService::new(self.config.clone(), self.api_client.clone());
246        service.sync().await
247    }
248
249    pub async fn sync_database(&self) -> SyncResult<SyncOperationResult> {
250        let local_db_url = self.config.local_database_url.as_ref().ok_or_else(|| {
251            SyncError::MissingConfig("local_database_url not configured".to_string())
252        })?;
253
254        let cloud_db_url = self
255            .api_client
256            .get_database_url(&self.config.tenant_id)
257            .await
258            .map_err(|e| SyncError::ApiError {
259                status: 500,
260                message: format!("Failed to get cloud database URL: {e}"),
261            })?;
262
263        let service = DatabaseSyncService::new(
264            self.config.direction,
265            self.config.dry_run,
266            local_db_url,
267            &cloud_db_url,
268        );
269
270        service.sync().await
271    }
272
273    pub async fn sync_all(&self) -> SyncResult<Vec<SyncOperationResult>> {
274        let mut results = Vec::new();
275
276        let files_result = self.sync_files().await?;
277        results.push(files_result);
278
279        match self.sync_database().await {
280            Ok(db_result) => results.push(db_result),
281            Err(e) => {
282                tracing::warn!(error = %e, "Database sync failed");
283                let (state, items_synced) = match &e {
284                    SyncError::MissingConfig(_) => (SyncOpState::NotStarted, 0),
285                    SyncError::PartialImport {
286                        completed, total, ..
287                    } => (
288                        SyncOpState::Partial {
289                            completed: *completed,
290                            total: *total,
291                        },
292                        *completed,
293                    ),
294                    _ => (SyncOpState::Failed, 0),
295                };
296                results.push(SyncOperationResult {
297                    operation: "database".to_string(),
298                    success: false,
299                    items_synced,
300                    items_skipped: 0,
301                    errors: vec![e.to_string()],
302                    details: None,
303                    state,
304                });
305            },
306        }
307
308        Ok(results)
309    }
310}