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::{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}