use serde::{Deserialize, Serialize};
use crate::changes::ChangeLifecycleFilter;
use crate::errors::DomainResult;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaimResult {
pub change_id: String,
pub holder: String,
pub expires_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseResult {
pub change_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AllocateResult {
pub claim: Option<ClaimResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaseConflict {
pub change_id: String,
pub holder: String,
pub expires_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtifactBundle {
pub change_id: String,
pub proposal: Option<String>,
pub design: Option<String>,
pub tasks: Option<String>,
pub specs: Vec<(String, String)>,
pub revision: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PushResult {
pub change_id: String,
pub new_revision: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RevisionConflict {
pub change_id: String,
pub local_revision: String,
pub server_revision: String,
}
#[derive(Debug, Clone)]
pub enum BackendError {
LeaseConflict(LeaseConflict),
RevisionConflict(RevisionConflict),
Unavailable(String),
Unauthorized(String),
NotFound(String),
Other(String),
}
impl std::fmt::Display for BackendError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BackendError::LeaseConflict(c) => {
write!(
f,
"change '{}' is already claimed by '{}'",
c.change_id, c.holder
)
}
BackendError::RevisionConflict(c) => {
write!(
f,
"revision conflict for '{}': local={}, server={}",
c.change_id, c.local_revision, c.server_revision
)
}
BackendError::Unavailable(msg) => write!(f, "backend unavailable: {msg}"),
BackendError::Unauthorized(msg) => write!(f, "backend auth failed: {msg}"),
BackendError::NotFound(msg) => write!(f, "not found: {msg}"),
BackendError::Other(msg) => write!(f, "backend error: {msg}"),
}
}
}
impl std::error::Error for BackendError {}
pub trait BackendProjectStore: Send + Sync {
fn change_repository(
&self,
org: &str,
repo: &str,
) -> DomainResult<Box<dyn crate::changes::ChangeRepository + Send>>;
fn module_repository(
&self,
org: &str,
repo: &str,
) -> DomainResult<Box<dyn crate::modules::ModuleRepository + Send>>;
fn task_repository(
&self,
org: &str,
repo: &str,
) -> DomainResult<Box<dyn crate::tasks::TaskRepository + Send>>;
fn task_mutation_service(
&self,
org: &str,
repo: &str,
) -> DomainResult<Box<dyn crate::tasks::TaskMutationService + Send>>;
fn spec_repository(
&self,
org: &str,
repo: &str,
) -> DomainResult<Box<dyn crate::specs::SpecRepository + Send>>;
fn pull_artifact_bundle(
&self,
org: &str,
repo: &str,
change_id: &str,
) -> Result<ArtifactBundle, BackendError>;
fn push_artifact_bundle(
&self,
org: &str,
repo: &str,
change_id: &str,
bundle: &ArtifactBundle,
) -> Result<PushResult, BackendError>;
fn archive_change(
&self,
org: &str,
repo: &str,
change_id: &str,
) -> Result<ArchiveResult, BackendError>;
fn ensure_project(&self, org: &str, repo: &str) -> DomainResult<()>;
fn project_exists(&self, org: &str, repo: &str) -> bool;
}
pub trait BackendLeaseClient {
fn claim(&self, change_id: &str) -> Result<ClaimResult, BackendError>;
fn release(&self, change_id: &str) -> Result<ReleaseResult, BackendError>;
fn allocate(&self) -> Result<AllocateResult, BackendError>;
}
pub trait BackendSyncClient {
fn pull(&self, change_id: &str) -> Result<ArtifactBundle, BackendError>;
fn push(&self, change_id: &str, bundle: &ArtifactBundle) -> Result<PushResult, BackendError>;
}
pub trait BackendChangeReader {
fn list_changes(
&self,
filter: ChangeLifecycleFilter,
) -> DomainResult<Vec<crate::changes::ChangeSummary>>;
fn get_change(
&self,
change_id: &str,
filter: ChangeLifecycleFilter,
) -> DomainResult<crate::changes::Change>;
}
pub trait BackendModuleReader {
fn list_modules(&self) -> DomainResult<Vec<crate::modules::ModuleSummary>>;
fn get_module(&self, module_id: &str) -> DomainResult<crate::modules::Module>;
}
pub trait BackendTaskReader {
fn load_tasks_content(&self, change_id: &str) -> DomainResult<Option<String>>;
}
pub trait BackendSpecReader {
fn list_specs(&self) -> DomainResult<Vec<crate::specs::SpecSummary>>;
fn get_spec(&self, spec_id: &str) -> DomainResult<crate::specs::SpecDocument>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventBatch {
pub events: Vec<crate::audit::event::AuditEvent>,
pub idempotency_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventIngestResult {
pub accepted: usize,
pub duplicates: usize,
}
pub trait BackendEventIngestClient {
fn ingest(&self, batch: &EventBatch) -> Result<EventIngestResult, BackendError>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchiveResult {
pub change_id: String,
pub archived_at: String,
}
pub trait BackendArchiveClient {
fn mark_archived(&self, change_id: &str) -> Result<ArchiveResult, BackendError>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backend_error_display_lease_conflict() {
let err = BackendError::LeaseConflict(LeaseConflict {
change_id: "024-02".to_string(),
holder: "agent-1".to_string(),
expires_at: None,
});
let msg = err.to_string();
assert!(msg.contains("024-02"));
assert!(msg.contains("agent-1"));
assert!(msg.contains("already claimed"));
}
#[test]
fn backend_error_display_revision_conflict() {
let err = BackendError::RevisionConflict(RevisionConflict {
change_id: "024-02".to_string(),
local_revision: "rev-1".to_string(),
server_revision: "rev-2".to_string(),
});
let msg = err.to_string();
assert!(msg.contains("024-02"));
assert!(msg.contains("rev-1"));
assert!(msg.contains("rev-2"));
}
#[test]
fn backend_error_display_unavailable() {
let err = BackendError::Unavailable("connection refused".to_string());
assert!(err.to_string().contains("connection refused"));
}
#[test]
fn backend_error_display_unauthorized() {
let err = BackendError::Unauthorized("invalid token".to_string());
assert!(err.to_string().contains("invalid token"));
}
#[test]
fn backend_error_display_not_found() {
let err = BackendError::NotFound("change xyz".to_string());
assert!(err.to_string().contains("change xyz"));
}
#[test]
fn backend_error_display_other() {
let err = BackendError::Other("unexpected".to_string());
assert!(err.to_string().contains("unexpected"));
}
#[test]
fn event_batch_roundtrip() {
let event = crate::audit::event::AuditEvent {
v: 1,
ts: "2026-02-28T10:00:00.000Z".to_string(),
entity: "task".to_string(),
entity_id: "1.1".to_string(),
scope: Some("test-change".to_string()),
op: "create".to_string(),
from: None,
to: Some("pending".to_string()),
actor: "cli".to_string(),
by: "@test".to_string(),
meta: None,
count: 1,
ctx: crate::audit::event::EventContext {
session_id: "sid".to_string(),
harness_session_id: None,
branch: None,
worktree: None,
commit: None,
},
};
let batch = EventBatch {
events: vec![event],
idempotency_key: "key-123".to_string(),
};
let json = serde_json::to_string(&batch).unwrap();
let restored: EventBatch = serde_json::from_str(&json).unwrap();
assert_eq!(restored.events.len(), 1);
assert_eq!(restored.idempotency_key, "key-123");
}
#[test]
fn event_ingest_result_roundtrip() {
let result = EventIngestResult {
accepted: 5,
duplicates: 2,
};
let json = serde_json::to_string(&result).unwrap();
let restored: EventIngestResult = serde_json::from_str(&json).unwrap();
assert_eq!(restored.accepted, 5);
assert_eq!(restored.duplicates, 2);
}
#[test]
fn archive_result_roundtrip() {
let result = ArchiveResult {
change_id: "024-05".to_string(),
archived_at: "2026-02-28T12:00:00Z".to_string(),
};
let json = serde_json::to_string(&result).unwrap();
let restored: ArchiveResult = serde_json::from_str(&json).unwrap();
assert_eq!(restored.change_id, "024-05");
assert_eq!(restored.archived_at, "2026-02-28T12:00:00Z");
}
#[test]
fn artifact_bundle_roundtrip() {
let bundle = ArtifactBundle {
change_id: "test-change".to_string(),
proposal: Some("# Proposal".to_string()),
design: None,
tasks: Some("- [ ] Task 1".to_string()),
specs: vec![("auth".to_string(), "## ADDED".to_string())],
revision: "rev-abc".to_string(),
};
let json = serde_json::to_string(&bundle).unwrap();
let restored: ArtifactBundle = serde_json::from_str(&json).unwrap();
assert_eq!(restored.change_id, "test-change");
assert_eq!(restored.revision, "rev-abc");
assert_eq!(restored.specs.len(), 1);
}
}