1pub 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}