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::changes::ChangeLifecycleFilter;
9use crate::errors::DomainResult;
10
11// ── Lease DTOs ──────────────────────────────────────────────────────
12
13/// Result of a successful change lease claim.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ClaimResult {
16    /// The change that was claimed.
17    pub change_id: String,
18    /// Identity of the lease holder.
19    pub holder: String,
20    /// Lease expiry as ISO-8601 timestamp, if available.
21    pub expires_at: Option<String>,
22}
23
24/// Result of a lease release operation.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ReleaseResult {
27    /// The change that was released.
28    pub change_id: String,
29}
30
31/// Result of an allocation operation.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct AllocateResult {
34    /// The allocated change, if any work was available.
35    pub claim: Option<ClaimResult>,
36}
37
38/// Conflict detail when a lease claim fails because another holder owns it.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct LeaseConflict {
41    /// The change that is already claimed.
42    pub change_id: String,
43    /// Current holder identity.
44    pub holder: String,
45    /// Lease expiry as ISO-8601 timestamp, if available.
46    pub expires_at: Option<String>,
47}
48
49// ── Sync DTOs ───────────────────────────────────────────────────────
50
51/// An artifact bundle pulled from the backend for a single change.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ArtifactBundle {
54    /// The change this bundle belongs to.
55    pub change_id: String,
56    /// Proposal markdown content, if present.
57    pub proposal: Option<String>,
58    /// Design markdown content, if present.
59    pub design: Option<String>,
60    /// Tasks markdown content, if present.
61    pub tasks: Option<String>,
62    /// Spec delta files: `(capability_name, content)` pairs.
63    pub specs: Vec<(String, String)>,
64    /// Backend revision identifier for optimistic concurrency.
65    pub revision: String,
66}
67
68/// Result of a push operation.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct PushResult {
71    /// The change whose artifacts were pushed.
72    pub change_id: String,
73    /// New revision after the push.
74    pub new_revision: String,
75}
76
77/// Conflict detail when a push fails due to a stale revision.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct RevisionConflict {
80    /// The change with the conflict.
81    pub change_id: String,
82    /// The local revision that was sent.
83    pub local_revision: String,
84    /// The current server revision.
85    pub server_revision: String,
86}
87
88// ── Backend error ───────────────────────────────────────────────────
89
90/// Backend operation error category.
91///
92/// Adapters convert this into the appropriate layer error type.
93#[derive(Debug, Clone)]
94pub enum BackendError {
95    /// The requested lease is held by another client.
96    LeaseConflict(LeaseConflict),
97    /// The push revision is stale.
98    RevisionConflict(RevisionConflict),
99    /// The backend is not reachable or returned a server error.
100    Unavailable(String),
101    /// Authentication failed (invalid or missing token).
102    Unauthorized(String),
103    /// The requested resource was not found.
104    NotFound(String),
105    /// A catch-all for unexpected errors.
106    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
136// ── Project store port ──────────────────────────────────────────────
137
138/// Port for resolving `{org}/{repo}` to project-level repositories.
139///
140/// The backend server uses this trait to obtain domain repository instances
141/// for a given project namespace. Implementations live in `ito-core` and
142/// may be backed by the filesystem or a database.
143///
144/// This trait is `Send + Sync` so it can be shared across async request
145/// handlers via `Arc`.
146pub trait BackendProjectStore: Send + Sync {
147    /// Obtain a change repository for the given project.
148    fn change_repository(
149        &self,
150        org: &str,
151        repo: &str,
152    ) -> DomainResult<Box<dyn crate::changes::ChangeRepository + Send>>;
153
154    /// Obtain a module repository for the given project.
155    fn module_repository(
156        &self,
157        org: &str,
158        repo: &str,
159    ) -> DomainResult<Box<dyn crate::modules::ModuleRepository + Send>>;
160
161    /// Obtain a task repository for the given project.
162    fn task_repository(
163        &self,
164        org: &str,
165        repo: &str,
166    ) -> DomainResult<Box<dyn crate::tasks::TaskRepository + Send>>;
167
168    /// Obtain a task mutation service for the given project.
169    fn task_mutation_service(
170        &self,
171        org: &str,
172        repo: &str,
173    ) -> DomainResult<Box<dyn crate::tasks::TaskMutationService + Send>>;
174
175    /// Obtain a promoted spec repository for the given project.
176    fn spec_repository(
177        &self,
178        org: &str,
179        repo: &str,
180    ) -> DomainResult<Box<dyn crate::specs::SpecRepository + Send>>;
181
182    /// Pull the latest artifact bundle for a change from backend-managed storage.
183    fn pull_artifact_bundle(
184        &self,
185        org: &str,
186        repo: &str,
187        change_id: &str,
188    ) -> Result<ArtifactBundle, BackendError>;
189
190    /// Push an updated artifact bundle into backend-managed storage.
191    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    /// Archive a change in backend-managed storage and mirror promoted specs.
200    fn archive_change(
201        &self,
202        org: &str,
203        repo: &str,
204        change_id: &str,
205    ) -> Result<ArchiveResult, BackendError>;
206
207    /// Ensure the project directory/storage structure exists.
208    ///
209    /// Called before first write to a project. Implementations should
210    /// create whatever backing store structure is needed.
211    fn ensure_project(&self, org: &str, repo: &str) -> DomainResult<()>;
212
213    /// Check whether the project exists in the store.
214    fn project_exists(&self, org: &str, repo: &str) -> bool;
215}
216
217// ── Port traits ─────────────────────────────────────────────────────
218
219/// Port for backend lease operations (claim, release, allocate).
220///
221/// Implementations handle HTTP communication and token management.
222/// The domain layer uses this trait to remain decoupled from transport.
223pub trait BackendLeaseClient {
224    /// Claim a lease on a change.
225    fn claim(&self, change_id: &str) -> Result<ClaimResult, BackendError>;
226
227    /// Release a held lease.
228    fn release(&self, change_id: &str) -> Result<ReleaseResult, BackendError>;
229
230    /// Request the backend to allocate the next available change.
231    fn allocate(&self) -> Result<AllocateResult, BackendError>;
232}
233
234/// Port for backend artifact synchronization operations.
235///
236/// Pull retrieves the latest artifact bundle for a change. Push sends
237/// local updates using optimistic concurrency (revision checks).
238pub trait BackendSyncClient {
239    /// Pull the latest artifact bundle for a change from the backend.
240    fn pull(&self, change_id: &str) -> Result<ArtifactBundle, BackendError>;
241
242    /// Push local artifact updates to the backend with a revision check.
243    fn push(&self, change_id: &str, bundle: &ArtifactBundle) -> Result<PushResult, BackendError>;
244}
245
246/// Port for backend-backed change listing (read path).
247///
248/// Used by repository adapters to resolve change data from the backend
249/// instead of the filesystem when backend mode is enabled.
250pub trait BackendChangeReader {
251    /// List all change summaries from the backend.
252    fn list_changes(
253        &self,
254        filter: ChangeLifecycleFilter,
255    ) -> DomainResult<Vec<crate::changes::ChangeSummary>>;
256
257    /// Get a full change from the backend.
258    fn get_change(
259        &self,
260        change_id: &str,
261        filter: ChangeLifecycleFilter,
262    ) -> DomainResult<crate::changes::Change>;
263}
264
265/// Port for backend-backed module listing.
266///
267/// Used by repository adapters to resolve module data from the backend when
268/// backend mode is enabled.
269pub trait BackendModuleReader {
270    /// List all module summaries from the backend.
271    fn list_modules(&self) -> DomainResult<Vec<crate::modules::ModuleSummary>>;
272
273    /// Get a full module from the backend.
274    fn get_module(&self, module_id: &str) -> DomainResult<crate::modules::Module>;
275}
276
277/// Port for backend-backed task reading.
278///
279/// Used by repository adapters to resolve task data from the backend
280/// when backend mode is enabled.
281pub trait BackendTaskReader {
282    /// Load tasks content (raw markdown) from the backend for a change.
283    fn load_tasks_content(&self, change_id: &str) -> DomainResult<Option<String>>;
284}
285
286/// Port for backend-backed promoted spec reading.
287pub trait BackendSpecReader {
288    /// List all promoted specs from the backend.
289    fn list_specs(&self) -> DomainResult<Vec<crate::specs::SpecSummary>>;
290
291    /// Get a promoted spec from the backend.
292    fn get_spec(&self, spec_id: &str) -> DomainResult<crate::specs::SpecDocument>;
293}
294
295// ── Event ingest DTOs ──────────────────────────────────────────────
296
297/// A batch of audit events to send to the backend.
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct EventBatch {
300    /// Events in this batch, serialized as JSON objects.
301    pub events: Vec<crate::audit::event::AuditEvent>,
302    /// Client-generated idempotency key for safe retries.
303    pub idempotency_key: String,
304}
305
306/// Result of a successful event ingest operation.
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct EventIngestResult {
309    /// Number of events accepted by the backend.
310    pub accepted: usize,
311    /// Number of events that were duplicates (already ingested).
312    pub duplicates: usize,
313}
314
315/// Port for backend event ingestion.
316///
317/// Implementations handle HTTP communication to submit local audit events
318/// to the backend for centralized observability.
319pub trait BackendEventIngestClient {
320    /// Submit a batch of audit events to the backend.
321    ///
322    /// The batch includes an idempotency key so retries do not produce
323    /// duplicate events on the server.
324    fn ingest(&self, batch: &EventBatch) -> Result<EventIngestResult, BackendError>;
325}
326
327// ── Archive DTOs ───────────────────────────────────────────────────
328
329/// Result of marking a change as archived on the backend.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct ArchiveResult {
332    /// The change that was archived.
333    pub change_id: String,
334    /// Timestamp when the backend recorded the archive (ISO-8601).
335    pub archived_at: String,
336}
337
338/// Port for backend archive lifecycle operations.
339///
340/// Marks a change as archived on the backend, making it immutable
341/// for subsequent backend operations (no further writes or leases).
342pub trait BackendArchiveClient {
343    /// Mark a change as archived on the backend.
344    ///
345    /// After this call succeeds, the backend SHALL reject further
346    /// write or lease operations for the change.
347    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}