Skip to main content

ito_domain/
backend.rs

1//! Backend coordination port definitions.
2//!
3//! Traits and DTOs for backend API operations: change leases (claim/release),
4//! allocation, and artifact synchronization. Implementations live in `ito-core`.
5
6use serde::{Deserialize, Serialize};
7
8use crate::errors::DomainResult;
9
10// ── Lease DTOs ──────────────────────────────────────────────────────
11
12/// Result of a successful change lease claim.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ClaimResult {
15    /// The change that was claimed.
16    pub change_id: String,
17    /// Identity of the lease holder.
18    pub holder: String,
19    /// Lease expiry as ISO-8601 timestamp, if available.
20    pub expires_at: Option<String>,
21}
22
23/// Result of a lease release operation.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ReleaseResult {
26    /// The change that was released.
27    pub change_id: String,
28}
29
30/// Result of an allocation operation.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct AllocateResult {
33    /// The allocated change, if any work was available.
34    pub claim: Option<ClaimResult>,
35}
36
37/// Conflict detail when a lease claim fails because another holder owns it.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct LeaseConflict {
40    /// The change that is already claimed.
41    pub change_id: String,
42    /// Current holder identity.
43    pub holder: String,
44    /// Lease expiry as ISO-8601 timestamp, if available.
45    pub expires_at: Option<String>,
46}
47
48// ── Sync DTOs ───────────────────────────────────────────────────────
49
50/// An artifact bundle pulled from the backend for a single change.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ArtifactBundle {
53    /// The change this bundle belongs to.
54    pub change_id: String,
55    /// Proposal markdown content, if present.
56    pub proposal: Option<String>,
57    /// Design markdown content, if present.
58    pub design: Option<String>,
59    /// Tasks markdown content, if present.
60    pub tasks: Option<String>,
61    /// Spec delta files: `(capability_name, content)` pairs.
62    pub specs: Vec<(String, String)>,
63    /// Backend revision identifier for optimistic concurrency.
64    pub revision: String,
65}
66
67/// Result of a push operation.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct PushResult {
70    /// The change whose artifacts were pushed.
71    pub change_id: String,
72    /// New revision after the push.
73    pub new_revision: String,
74}
75
76/// Conflict detail when a push fails due to a stale revision.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct RevisionConflict {
79    /// The change with the conflict.
80    pub change_id: String,
81    /// The local revision that was sent.
82    pub local_revision: String,
83    /// The current server revision.
84    pub server_revision: String,
85}
86
87// ── Backend error ───────────────────────────────────────────────────
88
89/// Backend operation error category.
90///
91/// Adapters convert this into the appropriate layer error type.
92#[derive(Debug, Clone)]
93pub enum BackendError {
94    /// The requested lease is held by another client.
95    LeaseConflict(LeaseConflict),
96    /// The push revision is stale.
97    RevisionConflict(RevisionConflict),
98    /// The backend is not reachable or returned a server error.
99    Unavailable(String),
100    /// Authentication failed (invalid or missing token).
101    Unauthorized(String),
102    /// The requested resource was not found.
103    NotFound(String),
104    /// A catch-all for unexpected errors.
105    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
135// ── Port traits ─────────────────────────────────────────────────────
136
137/// Port for backend lease operations (claim, release, allocate).
138///
139/// Implementations handle HTTP communication and token management.
140/// The domain layer uses this trait to remain decoupled from transport.
141pub trait BackendLeaseClient {
142    /// Claim a lease on a change.
143    fn claim(&self, change_id: &str) -> Result<ClaimResult, BackendError>;
144
145    /// Release a held lease.
146    fn release(&self, change_id: &str) -> Result<ReleaseResult, BackendError>;
147
148    /// Request the backend to allocate the next available change.
149    fn allocate(&self) -> Result<AllocateResult, BackendError>;
150}
151
152/// Port for backend artifact synchronization operations.
153///
154/// Pull retrieves the latest artifact bundle for a change. Push sends
155/// local updates using optimistic concurrency (revision checks).
156pub trait BackendSyncClient {
157    /// Pull the latest artifact bundle for a change from the backend.
158    fn pull(&self, change_id: &str) -> Result<ArtifactBundle, BackendError>;
159
160    /// Push local artifact updates to the backend with a revision check.
161    fn push(&self, change_id: &str, bundle: &ArtifactBundle) -> Result<PushResult, BackendError>;
162}
163
164/// Port for backend-backed change listing (read path).
165///
166/// Used by repository adapters to resolve change data from the backend
167/// instead of the filesystem when backend mode is enabled.
168pub trait BackendChangeReader {
169    /// List all change summaries from the backend.
170    fn list_changes(&self) -> DomainResult<Vec<crate::changes::ChangeSummary>>;
171
172    /// Get a full change from the backend.
173    fn get_change(&self, change_id: &str) -> DomainResult<crate::changes::Change>;
174}
175
176/// Port for backend-backed task reading.
177///
178/// Used by repository adapters to resolve task data from the backend
179/// when backend mode is enabled.
180pub trait BackendTaskReader {
181    /// Load tasks content (raw markdown) from the backend for a change.
182    fn load_tasks_content(&self, change_id: &str) -> DomainResult<Option<String>>;
183}
184
185// ── Event ingest DTOs ──────────────────────────────────────────────
186
187/// A batch of audit events to send to the backend.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct EventBatch {
190    /// Events in this batch, serialized as JSON objects.
191    pub events: Vec<crate::audit::event::AuditEvent>,
192    /// Client-generated idempotency key for safe retries.
193    pub idempotency_key: String,
194}
195
196/// Result of a successful event ingest operation.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct EventIngestResult {
199    /// Number of events accepted by the backend.
200    pub accepted: usize,
201    /// Number of events that were duplicates (already ingested).
202    pub duplicates: usize,
203}
204
205/// Port for backend event ingestion.
206///
207/// Implementations handle HTTP communication to submit local audit events
208/// to the backend for centralized observability.
209pub trait BackendEventIngestClient {
210    /// Submit a batch of audit events to the backend.
211    ///
212    /// The batch includes an idempotency key so retries do not produce
213    /// duplicate events on the server.
214    fn ingest(&self, batch: &EventBatch) -> Result<EventIngestResult, BackendError>;
215}
216
217// ── Archive DTOs ───────────────────────────────────────────────────
218
219/// Result of marking a change as archived on the backend.
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct ArchiveResult {
222    /// The change that was archived.
223    pub change_id: String,
224    /// Timestamp when the backend recorded the archive (ISO-8601).
225    pub archived_at: String,
226}
227
228/// Port for backend archive lifecycle operations.
229///
230/// Marks a change as archived on the backend, making it immutable
231/// for subsequent backend operations (no further writes or leases).
232pub trait BackendArchiveClient {
233    /// Mark a change as archived on the backend.
234    ///
235    /// After this call succeeds, the backend SHALL reject further
236    /// write or lease operations for the change.
237    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}