1use serde::{Deserialize, Serialize};
7
8use crate::errors::DomainResult;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ClaimResult {
15 pub change_id: String,
17 pub holder: String,
19 pub expires_at: Option<String>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ReleaseResult {
26 pub change_id: String,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct AllocateResult {
33 pub claim: Option<ClaimResult>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct LeaseConflict {
40 pub change_id: String,
42 pub holder: String,
44 pub expires_at: Option<String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ArtifactBundle {
53 pub change_id: String,
55 pub proposal: Option<String>,
57 pub design: Option<String>,
59 pub tasks: Option<String>,
61 pub specs: Vec<(String, String)>,
63 pub revision: String,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct PushResult {
70 pub change_id: String,
72 pub new_revision: String,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct RevisionConflict {
79 pub change_id: String,
81 pub local_revision: String,
83 pub server_revision: String,
85}
86
87#[derive(Debug, Clone)]
93pub enum BackendError {
94 LeaseConflict(LeaseConflict),
96 RevisionConflict(RevisionConflict),
98 Unavailable(String),
100 Unauthorized(String),
102 NotFound(String),
104 Other(String),
106}
107
108impl std::fmt::Display for BackendError {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 match self {
111 BackendError::LeaseConflict(c) => {
112 write!(
113 f,
114 "change '{}' is already claimed by '{}'",
115 c.change_id, c.holder
116 )
117 }
118 BackendError::RevisionConflict(c) => {
119 write!(
120 f,
121 "revision conflict for '{}': local={}, server={}",
122 c.change_id, c.local_revision, c.server_revision
123 )
124 }
125 BackendError::Unavailable(msg) => write!(f, "backend unavailable: {msg}"),
126 BackendError::Unauthorized(msg) => write!(f, "backend auth failed: {msg}"),
127 BackendError::NotFound(msg) => write!(f, "not found: {msg}"),
128 BackendError::Other(msg) => write!(f, "backend error: {msg}"),
129 }
130 }
131}
132
133impl std::error::Error for BackendError {}
134
135pub trait BackendProjectStore: Send + Sync {
146 fn change_repository(
148 &self,
149 org: &str,
150 repo: &str,
151 ) -> DomainResult<Box<dyn crate::changes::ChangeRepository + Send>>;
152
153 fn module_repository(
155 &self,
156 org: &str,
157 repo: &str,
158 ) -> DomainResult<Box<dyn crate::modules::ModuleRepository + Send>>;
159
160 fn task_repository(
162 &self,
163 org: &str,
164 repo: &str,
165 ) -> DomainResult<Box<dyn crate::tasks::TaskRepository + Send>>;
166
167 fn ensure_project(&self, org: &str, repo: &str) -> DomainResult<()>;
172
173 fn project_exists(&self, org: &str, repo: &str) -> bool;
175}
176
177pub trait BackendLeaseClient {
184 fn claim(&self, change_id: &str) -> Result<ClaimResult, BackendError>;
186
187 fn release(&self, change_id: &str) -> Result<ReleaseResult, BackendError>;
189
190 fn allocate(&self) -> Result<AllocateResult, BackendError>;
192}
193
194pub trait BackendSyncClient {
199 fn pull(&self, change_id: &str) -> Result<ArtifactBundle, BackendError>;
201
202 fn push(&self, change_id: &str, bundle: &ArtifactBundle) -> Result<PushResult, BackendError>;
204}
205
206pub trait BackendChangeReader {
211 fn list_changes(&self) -> DomainResult<Vec<crate::changes::ChangeSummary>>;
213
214 fn get_change(&self, change_id: &str) -> DomainResult<crate::changes::Change>;
216}
217
218pub trait BackendTaskReader {
223 fn load_tasks_content(&self, change_id: &str) -> DomainResult<Option<String>>;
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct EventBatch {
232 pub events: Vec<crate::audit::event::AuditEvent>,
234 pub idempotency_key: String,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct EventIngestResult {
241 pub accepted: usize,
243 pub duplicates: usize,
245}
246
247pub trait BackendEventIngestClient {
252 fn ingest(&self, batch: &EventBatch) -> Result<EventIngestResult, BackendError>;
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct ArchiveResult {
264 pub change_id: String,
266 pub archived_at: String,
268}
269
270pub trait BackendArchiveClient {
275 fn mark_archived(&self, change_id: &str) -> Result<ArchiveResult, BackendError>;
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn backend_error_display_lease_conflict() {
288 let err = BackendError::LeaseConflict(LeaseConflict {
289 change_id: "024-02".to_string(),
290 holder: "agent-1".to_string(),
291 expires_at: None,
292 });
293 let msg = err.to_string();
294 assert!(msg.contains("024-02"));
295 assert!(msg.contains("agent-1"));
296 assert!(msg.contains("already claimed"));
297 }
298
299 #[test]
300 fn backend_error_display_revision_conflict() {
301 let err = BackendError::RevisionConflict(RevisionConflict {
302 change_id: "024-02".to_string(),
303 local_revision: "rev-1".to_string(),
304 server_revision: "rev-2".to_string(),
305 });
306 let msg = err.to_string();
307 assert!(msg.contains("024-02"));
308 assert!(msg.contains("rev-1"));
309 assert!(msg.contains("rev-2"));
310 }
311
312 #[test]
313 fn backend_error_display_unavailable() {
314 let err = BackendError::Unavailable("connection refused".to_string());
315 assert!(err.to_string().contains("connection refused"));
316 }
317
318 #[test]
319 fn backend_error_display_unauthorized() {
320 let err = BackendError::Unauthorized("invalid token".to_string());
321 assert!(err.to_string().contains("invalid token"));
322 }
323
324 #[test]
325 fn backend_error_display_not_found() {
326 let err = BackendError::NotFound("change xyz".to_string());
327 assert!(err.to_string().contains("change xyz"));
328 }
329
330 #[test]
331 fn backend_error_display_other() {
332 let err = BackendError::Other("unexpected".to_string());
333 assert!(err.to_string().contains("unexpected"));
334 }
335
336 #[test]
337 fn event_batch_roundtrip() {
338 let event = crate::audit::event::AuditEvent {
339 v: 1,
340 ts: "2026-02-28T10:00:00.000Z".to_string(),
341 entity: "task".to_string(),
342 entity_id: "1.1".to_string(),
343 scope: Some("test-change".to_string()),
344 op: "create".to_string(),
345 from: None,
346 to: Some("pending".to_string()),
347 actor: "cli".to_string(),
348 by: "@test".to_string(),
349 meta: None,
350 ctx: crate::audit::event::EventContext {
351 session_id: "sid".to_string(),
352 harness_session_id: None,
353 branch: None,
354 worktree: None,
355 commit: None,
356 },
357 };
358 let batch = EventBatch {
359 events: vec![event],
360 idempotency_key: "key-123".to_string(),
361 };
362 let json = serde_json::to_string(&batch).unwrap();
363 let restored: EventBatch = serde_json::from_str(&json).unwrap();
364 assert_eq!(restored.events.len(), 1);
365 assert_eq!(restored.idempotency_key, "key-123");
366 }
367
368 #[test]
369 fn event_ingest_result_roundtrip() {
370 let result = EventIngestResult {
371 accepted: 5,
372 duplicates: 2,
373 };
374 let json = serde_json::to_string(&result).unwrap();
375 let restored: EventIngestResult = serde_json::from_str(&json).unwrap();
376 assert_eq!(restored.accepted, 5);
377 assert_eq!(restored.duplicates, 2);
378 }
379
380 #[test]
381 fn archive_result_roundtrip() {
382 let result = ArchiveResult {
383 change_id: "024-05".to_string(),
384 archived_at: "2026-02-28T12:00:00Z".to_string(),
385 };
386 let json = serde_json::to_string(&result).unwrap();
387 let restored: ArchiveResult = serde_json::from_str(&json).unwrap();
388 assert_eq!(restored.change_id, "024-05");
389 assert_eq!(restored.archived_at, "2026-02-28T12:00:00Z");
390 }
391
392 #[test]
393 fn artifact_bundle_roundtrip() {
394 let bundle = ArtifactBundle {
395 change_id: "test-change".to_string(),
396 proposal: Some("# Proposal".to_string()),
397 design: None,
398 tasks: Some("- [ ] Task 1".to_string()),
399 specs: vec![("auth".to_string(), "## ADDED".to_string())],
400 revision: "rev-abc".to_string(),
401 };
402 let json = serde_json::to_string(&bundle).unwrap();
403 let restored: ArtifactBundle = serde_json::from_str(&json).unwrap();
404 assert_eq!(restored.change_id, "test-change");
405 assert_eq!(restored.revision, "rev-abc");
406 assert_eq!(restored.specs.len(), 1);
407 }
408}