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// ── Project store port ──────────────────────────────────────────────
136
137/// Port for resolving `{org}/{repo}` to project-level repositories.
138///
139/// The backend server uses this trait to obtain domain repository instances
140/// for a given project namespace. Implementations live in `ito-core` and
141/// may be backed by the filesystem or a database.
142///
143/// This trait is `Send + Sync` so it can be shared across async request
144/// handlers via `Arc`.
145pub trait BackendProjectStore: Send + Sync {
146    /// Obtain a change repository for the given project.
147    fn change_repository(
148        &self,
149        org: &str,
150        repo: &str,
151    ) -> DomainResult<Box<dyn crate::changes::ChangeRepository + Send>>;
152
153    /// Obtain a module repository for the given project.
154    fn module_repository(
155        &self,
156        org: &str,
157        repo: &str,
158    ) -> DomainResult<Box<dyn crate::modules::ModuleRepository + Send>>;
159
160    /// Obtain a task repository for the given project.
161    fn task_repository(
162        &self,
163        org: &str,
164        repo: &str,
165    ) -> DomainResult<Box<dyn crate::tasks::TaskRepository + Send>>;
166
167    /// Ensure the project directory/storage structure exists.
168    ///
169    /// Called before first write to a project. Implementations should
170    /// create whatever backing store structure is needed.
171    fn ensure_project(&self, org: &str, repo: &str) -> DomainResult<()>;
172
173    /// Check whether the project exists in the store.
174    fn project_exists(&self, org: &str, repo: &str) -> bool;
175}
176
177// ── Port traits ─────────────────────────────────────────────────────
178
179/// Port for backend lease operations (claim, release, allocate).
180///
181/// Implementations handle HTTP communication and token management.
182/// The domain layer uses this trait to remain decoupled from transport.
183pub trait BackendLeaseClient {
184    /// Claim a lease on a change.
185    fn claim(&self, change_id: &str) -> Result<ClaimResult, BackendError>;
186
187    /// Release a held lease.
188    fn release(&self, change_id: &str) -> Result<ReleaseResult, BackendError>;
189
190    /// Request the backend to allocate the next available change.
191    fn allocate(&self) -> Result<AllocateResult, BackendError>;
192}
193
194/// Port for backend artifact synchronization operations.
195///
196/// Pull retrieves the latest artifact bundle for a change. Push sends
197/// local updates using optimistic concurrency (revision checks).
198pub trait BackendSyncClient {
199    /// Pull the latest artifact bundle for a change from the backend.
200    fn pull(&self, change_id: &str) -> Result<ArtifactBundle, BackendError>;
201
202    /// Push local artifact updates to the backend with a revision check.
203    fn push(&self, change_id: &str, bundle: &ArtifactBundle) -> Result<PushResult, BackendError>;
204}
205
206/// Port for backend-backed change listing (read path).
207///
208/// Used by repository adapters to resolve change data from the backend
209/// instead of the filesystem when backend mode is enabled.
210pub trait BackendChangeReader {
211    /// List all change summaries from the backend.
212    fn list_changes(&self) -> DomainResult<Vec<crate::changes::ChangeSummary>>;
213
214    /// Get a full change from the backend.
215    fn get_change(&self, change_id: &str) -> DomainResult<crate::changes::Change>;
216}
217
218/// Port for backend-backed task reading.
219///
220/// Used by repository adapters to resolve task data from the backend
221/// when backend mode is enabled.
222pub trait BackendTaskReader {
223    /// Load tasks content (raw markdown) from the backend for a change.
224    fn load_tasks_content(&self, change_id: &str) -> DomainResult<Option<String>>;
225}
226
227// ── Event ingest DTOs ──────────────────────────────────────────────
228
229/// A batch of audit events to send to the backend.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct EventBatch {
232    /// Events in this batch, serialized as JSON objects.
233    pub events: Vec<crate::audit::event::AuditEvent>,
234    /// Client-generated idempotency key for safe retries.
235    pub idempotency_key: String,
236}
237
238/// Result of a successful event ingest operation.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct EventIngestResult {
241    /// Number of events accepted by the backend.
242    pub accepted: usize,
243    /// Number of events that were duplicates (already ingested).
244    pub duplicates: usize,
245}
246
247/// Port for backend event ingestion.
248///
249/// Implementations handle HTTP communication to submit local audit events
250/// to the backend for centralized observability.
251pub trait BackendEventIngestClient {
252    /// Submit a batch of audit events to the backend.
253    ///
254    /// The batch includes an idempotency key so retries do not produce
255    /// duplicate events on the server.
256    fn ingest(&self, batch: &EventBatch) -> Result<EventIngestResult, BackendError>;
257}
258
259// ── Archive DTOs ───────────────────────────────────────────────────
260
261/// Result of marking a change as archived on the backend.
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct ArchiveResult {
264    /// The change that was archived.
265    pub change_id: String,
266    /// Timestamp when the backend recorded the archive (ISO-8601).
267    pub archived_at: String,
268}
269
270/// Port for backend archive lifecycle operations.
271///
272/// Marks a change as archived on the backend, making it immutable
273/// for subsequent backend operations (no further writes or leases).
274pub trait BackendArchiveClient {
275    /// Mark a change as archived on the backend.
276    ///
277    /// After this call succeeds, the backend SHALL reject further
278    /// write or lease operations for the change.
279    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}