1use serde::{Deserialize, Serialize};
7
8use crate::changes::ChangeLifecycleFilter;
9use crate::errors::DomainResult;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ClaimResult {
16 pub change_id: String,
18 pub holder: String,
20 pub expires_at: Option<String>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ReleaseResult {
27 pub change_id: String,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct AllocateResult {
34 pub claim: Option<ClaimResult>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct LeaseConflict {
41 pub change_id: String,
43 pub holder: String,
45 pub expires_at: Option<String>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ArtifactBundle {
54 pub change_id: String,
56 pub proposal: Option<String>,
58 pub design: Option<String>,
60 pub tasks: Option<String>,
62 pub specs: Vec<(String, String)>,
64 pub revision: String,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct PushResult {
71 pub change_id: String,
73 pub new_revision: String,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct RevisionConflict {
80 pub change_id: String,
82 pub local_revision: String,
84 pub server_revision: String,
86}
87
88#[derive(Debug, Clone)]
94pub enum BackendError {
95 LeaseConflict(LeaseConflict),
97 RevisionConflict(RevisionConflict),
99 Unavailable(String),
101 Unauthorized(String),
103 NotFound(String),
105 Other(String),
107}
108
109impl std::fmt::Display for BackendError {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 match self {
112 BackendError::LeaseConflict(c) => {
113 write!(
114 f,
115 "change '{}' is already claimed by '{}'",
116 c.change_id, c.holder
117 )
118 }
119 BackendError::RevisionConflict(c) => {
120 write!(
121 f,
122 "revision conflict for '{}': local={}, server={}",
123 c.change_id, c.local_revision, c.server_revision
124 )
125 }
126 BackendError::Unavailable(msg) => write!(f, "backend unavailable: {msg}"),
127 BackendError::Unauthorized(msg) => write!(f, "backend auth failed: {msg}"),
128 BackendError::NotFound(msg) => write!(f, "not found: {msg}"),
129 BackendError::Other(msg) => write!(f, "backend error: {msg}"),
130 }
131 }
132}
133
134impl std::error::Error for BackendError {}
135
136pub trait BackendProjectStore: Send + Sync {
147 fn change_repository(
149 &self,
150 org: &str,
151 repo: &str,
152 ) -> DomainResult<Box<dyn crate::changes::ChangeRepository + Send>>;
153
154 fn module_repository(
156 &self,
157 org: &str,
158 repo: &str,
159 ) -> DomainResult<Box<dyn crate::modules::ModuleRepository + Send>>;
160
161 fn task_repository(
163 &self,
164 org: &str,
165 repo: &str,
166 ) -> DomainResult<Box<dyn crate::tasks::TaskRepository + Send>>;
167
168 fn task_mutation_service(
170 &self,
171 org: &str,
172 repo: &str,
173 ) -> DomainResult<Box<dyn crate::tasks::TaskMutationService + Send>>;
174
175 fn spec_repository(
177 &self,
178 org: &str,
179 repo: &str,
180 ) -> DomainResult<Box<dyn crate::specs::SpecRepository + Send>>;
181
182 fn pull_artifact_bundle(
184 &self,
185 org: &str,
186 repo: &str,
187 change_id: &str,
188 ) -> Result<ArtifactBundle, BackendError>;
189
190 fn push_artifact_bundle(
192 &self,
193 org: &str,
194 repo: &str,
195 change_id: &str,
196 bundle: &ArtifactBundle,
197 ) -> Result<PushResult, BackendError>;
198
199 fn archive_change(
201 &self,
202 org: &str,
203 repo: &str,
204 change_id: &str,
205 ) -> Result<ArchiveResult, BackendError>;
206
207 fn ensure_project(&self, org: &str, repo: &str) -> DomainResult<()>;
212
213 fn project_exists(&self, org: &str, repo: &str) -> bool;
215}
216
217pub trait BackendLeaseClient {
224 fn claim(&self, change_id: &str) -> Result<ClaimResult, BackendError>;
226
227 fn release(&self, change_id: &str) -> Result<ReleaseResult, BackendError>;
229
230 fn allocate(&self) -> Result<AllocateResult, BackendError>;
232}
233
234pub trait BackendSyncClient {
239 fn pull(&self, change_id: &str) -> Result<ArtifactBundle, BackendError>;
241
242 fn push(&self, change_id: &str, bundle: &ArtifactBundle) -> Result<PushResult, BackendError>;
244}
245
246pub trait BackendChangeReader {
251 fn list_changes(
253 &self,
254 filter: ChangeLifecycleFilter,
255 ) -> DomainResult<Vec<crate::changes::ChangeSummary>>;
256
257 fn get_change(
259 &self,
260 change_id: &str,
261 filter: ChangeLifecycleFilter,
262 ) -> DomainResult<crate::changes::Change>;
263}
264
265pub trait BackendModuleReader {
270 fn list_modules(&self) -> DomainResult<Vec<crate::modules::ModuleSummary>>;
272
273 fn get_module(&self, module_id: &str) -> DomainResult<crate::modules::Module>;
275}
276
277pub trait BackendTaskReader {
282 fn load_tasks_content(&self, change_id: &str) -> DomainResult<Option<String>>;
284}
285
286pub trait BackendSpecReader {
288 fn list_specs(&self) -> DomainResult<Vec<crate::specs::SpecSummary>>;
290
291 fn get_spec(&self, spec_id: &str) -> DomainResult<crate::specs::SpecDocument>;
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct EventBatch {
300 pub events: Vec<crate::audit::event::AuditEvent>,
302 pub idempotency_key: String,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct EventIngestResult {
309 pub accepted: usize,
311 pub duplicates: usize,
313}
314
315pub trait BackendEventIngestClient {
320 fn ingest(&self, batch: &EventBatch) -> Result<EventIngestResult, BackendError>;
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct ArchiveResult {
332 pub change_id: String,
334 pub archived_at: String,
336}
337
338pub trait BackendArchiveClient {
343 fn mark_archived(&self, change_id: &str) -> Result<ArchiveResult, BackendError>;
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
355 fn backend_error_display_lease_conflict() {
356 let err = BackendError::LeaseConflict(LeaseConflict {
357 change_id: "024-02".to_string(),
358 holder: "agent-1".to_string(),
359 expires_at: None,
360 });
361 let msg = err.to_string();
362 assert!(msg.contains("024-02"));
363 assert!(msg.contains("agent-1"));
364 assert!(msg.contains("already claimed"));
365 }
366
367 #[test]
368 fn backend_error_display_revision_conflict() {
369 let err = BackendError::RevisionConflict(RevisionConflict {
370 change_id: "024-02".to_string(),
371 local_revision: "rev-1".to_string(),
372 server_revision: "rev-2".to_string(),
373 });
374 let msg = err.to_string();
375 assert!(msg.contains("024-02"));
376 assert!(msg.contains("rev-1"));
377 assert!(msg.contains("rev-2"));
378 }
379
380 #[test]
381 fn backend_error_display_unavailable() {
382 let err = BackendError::Unavailable("connection refused".to_string());
383 assert!(err.to_string().contains("connection refused"));
384 }
385
386 #[test]
387 fn backend_error_display_unauthorized() {
388 let err = BackendError::Unauthorized("invalid token".to_string());
389 assert!(err.to_string().contains("invalid token"));
390 }
391
392 #[test]
393 fn backend_error_display_not_found() {
394 let err = BackendError::NotFound("change xyz".to_string());
395 assert!(err.to_string().contains("change xyz"));
396 }
397
398 #[test]
399 fn backend_error_display_other() {
400 let err = BackendError::Other("unexpected".to_string());
401 assert!(err.to_string().contains("unexpected"));
402 }
403
404 #[test]
405 fn event_batch_roundtrip() {
406 let event = crate::audit::event::AuditEvent {
407 v: 1,
408 ts: "2026-02-28T10:00:00.000Z".to_string(),
409 entity: "task".to_string(),
410 entity_id: "1.1".to_string(),
411 scope: Some("test-change".to_string()),
412 op: "create".to_string(),
413 from: None,
414 to: Some("pending".to_string()),
415 actor: "cli".to_string(),
416 by: "@test".to_string(),
417 meta: None,
418 ctx: crate::audit::event::EventContext {
419 session_id: "sid".to_string(),
420 harness_session_id: None,
421 branch: None,
422 worktree: None,
423 commit: None,
424 },
425 };
426 let batch = EventBatch {
427 events: vec![event],
428 idempotency_key: "key-123".to_string(),
429 };
430 let json = serde_json::to_string(&batch).unwrap();
431 let restored: EventBatch = serde_json::from_str(&json).unwrap();
432 assert_eq!(restored.events.len(), 1);
433 assert_eq!(restored.idempotency_key, "key-123");
434 }
435
436 #[test]
437 fn event_ingest_result_roundtrip() {
438 let result = EventIngestResult {
439 accepted: 5,
440 duplicates: 2,
441 };
442 let json = serde_json::to_string(&result).unwrap();
443 let restored: EventIngestResult = serde_json::from_str(&json).unwrap();
444 assert_eq!(restored.accepted, 5);
445 assert_eq!(restored.duplicates, 2);
446 }
447
448 #[test]
449 fn archive_result_roundtrip() {
450 let result = ArchiveResult {
451 change_id: "024-05".to_string(),
452 archived_at: "2026-02-28T12:00:00Z".to_string(),
453 };
454 let json = serde_json::to_string(&result).unwrap();
455 let restored: ArchiveResult = serde_json::from_str(&json).unwrap();
456 assert_eq!(restored.change_id, "024-05");
457 assert_eq!(restored.archived_at, "2026-02-28T12:00:00Z");
458 }
459
460 #[test]
461 fn artifact_bundle_roundtrip() {
462 let bundle = ArtifactBundle {
463 change_id: "test-change".to_string(),
464 proposal: Some("# Proposal".to_string()),
465 design: None,
466 tasks: Some("- [ ] Task 1".to_string()),
467 specs: vec![("auth".to_string(), "## ADDED".to_string())],
468 revision: "rev-abc".to_string(),
469 };
470 let json = serde_json::to_string(&bundle).unwrap();
471 let restored: ArtifactBundle = serde_json::from_str(&json).unwrap();
472 assert_eq!(restored.change_id, "test-change");
473 assert_eq!(restored.revision, "rev-abc");
474 assert_eq!(restored.specs.len(), 1);
475 }
476}