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 BackendLeaseClient {
142 fn claim(&self, change_id: &str) -> Result<ClaimResult, BackendError>;
144
145 fn release(&self, change_id: &str) -> Result<ReleaseResult, BackendError>;
147
148 fn allocate(&self) -> Result<AllocateResult, BackendError>;
150}
151
152pub trait BackendSyncClient {
157 fn pull(&self, change_id: &str) -> Result<ArtifactBundle, BackendError>;
159
160 fn push(&self, change_id: &str, bundle: &ArtifactBundle) -> Result<PushResult, BackendError>;
162}
163
164pub trait BackendChangeReader {
169 fn list_changes(&self) -> DomainResult<Vec<crate::changes::ChangeSummary>>;
171
172 fn get_change(&self, change_id: &str) -> DomainResult<crate::changes::Change>;
174}
175
176pub trait BackendTaskReader {
181 fn load_tasks_content(&self, change_id: &str) -> DomainResult<Option<String>>;
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct EventBatch {
190 pub events: Vec<crate::audit::event::AuditEvent>,
192 pub idempotency_key: String,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct EventIngestResult {
199 pub accepted: usize,
201 pub duplicates: usize,
203}
204
205pub trait BackendEventIngestClient {
210 fn ingest(&self, batch: &EventBatch) -> Result<EventIngestResult, BackendError>;
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct ArchiveResult {
222 pub change_id: String,
224 pub archived_at: String,
226}
227
228pub trait BackendArchiveClient {
233 fn mark_archived(&self, change_id: &str) -> Result<ArchiveResult, BackendError>;
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn backend_error_display_lease_conflict() {
246 let err = BackendError::LeaseConflict(LeaseConflict {
247 change_id: "024-02".to_string(),
248 holder: "agent-1".to_string(),
249 expires_at: None,
250 });
251 let msg = err.to_string();
252 assert!(msg.contains("024-02"));
253 assert!(msg.contains("agent-1"));
254 assert!(msg.contains("already claimed"));
255 }
256
257 #[test]
258 fn backend_error_display_revision_conflict() {
259 let err = BackendError::RevisionConflict(RevisionConflict {
260 change_id: "024-02".to_string(),
261 local_revision: "rev-1".to_string(),
262 server_revision: "rev-2".to_string(),
263 });
264 let msg = err.to_string();
265 assert!(msg.contains("024-02"));
266 assert!(msg.contains("rev-1"));
267 assert!(msg.contains("rev-2"));
268 }
269
270 #[test]
271 fn backend_error_display_unavailable() {
272 let err = BackendError::Unavailable("connection refused".to_string());
273 assert!(err.to_string().contains("connection refused"));
274 }
275
276 #[test]
277 fn backend_error_display_unauthorized() {
278 let err = BackendError::Unauthorized("invalid token".to_string());
279 assert!(err.to_string().contains("invalid token"));
280 }
281
282 #[test]
283 fn backend_error_display_not_found() {
284 let err = BackendError::NotFound("change xyz".to_string());
285 assert!(err.to_string().contains("change xyz"));
286 }
287
288 #[test]
289 fn backend_error_display_other() {
290 let err = BackendError::Other("unexpected".to_string());
291 assert!(err.to_string().contains("unexpected"));
292 }
293
294 #[test]
295 fn event_batch_roundtrip() {
296 let event = crate::audit::event::AuditEvent {
297 v: 1,
298 ts: "2026-02-28T10:00:00.000Z".to_string(),
299 entity: "task".to_string(),
300 entity_id: "1.1".to_string(),
301 scope: Some("test-change".to_string()),
302 op: "create".to_string(),
303 from: None,
304 to: Some("pending".to_string()),
305 actor: "cli".to_string(),
306 by: "@test".to_string(),
307 meta: None,
308 ctx: crate::audit::event::EventContext {
309 session_id: "sid".to_string(),
310 harness_session_id: None,
311 branch: None,
312 worktree: None,
313 commit: None,
314 },
315 };
316 let batch = EventBatch {
317 events: vec![event],
318 idempotency_key: "key-123".to_string(),
319 };
320 let json = serde_json::to_string(&batch).unwrap();
321 let restored: EventBatch = serde_json::from_str(&json).unwrap();
322 assert_eq!(restored.events.len(), 1);
323 assert_eq!(restored.idempotency_key, "key-123");
324 }
325
326 #[test]
327 fn event_ingest_result_roundtrip() {
328 let result = EventIngestResult {
329 accepted: 5,
330 duplicates: 2,
331 };
332 let json = serde_json::to_string(&result).unwrap();
333 let restored: EventIngestResult = serde_json::from_str(&json).unwrap();
334 assert_eq!(restored.accepted, 5);
335 assert_eq!(restored.duplicates, 2);
336 }
337
338 #[test]
339 fn archive_result_roundtrip() {
340 let result = ArchiveResult {
341 change_id: "024-05".to_string(),
342 archived_at: "2026-02-28T12:00:00Z".to_string(),
343 };
344 let json = serde_json::to_string(&result).unwrap();
345 let restored: ArchiveResult = serde_json::from_str(&json).unwrap();
346 assert_eq!(restored.change_id, "024-05");
347 assert_eq!(restored.archived_at, "2026-02-28T12:00:00Z");
348 }
349
350 #[test]
351 fn artifact_bundle_roundtrip() {
352 let bundle = ArtifactBundle {
353 change_id: "test-change".to_string(),
354 proposal: Some("# Proposal".to_string()),
355 design: None,
356 tasks: Some("- [ ] Task 1".to_string()),
357 specs: vec![("auth".to_string(), "## ADDED".to_string())],
358 revision: "rev-abc".to_string(),
359 };
360 let json = serde_json::to_string(&bundle).unwrap();
361 let restored: ArtifactBundle = serde_json::from_str(&json).unwrap();
362 assert_eq!(restored.change_id, "test-change");
363 assert_eq!(restored.revision, "rev-abc");
364 assert_eq!(restored.specs.len(), 1);
365 }
366}