use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PlanId(pub String);
impl PlanId {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for PlanId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct CallerId(pub String);
impl CallerId {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for CallerId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanGraph {
pub deliverables: Vec<Deliverable>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_chained_dispatch: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Deliverable {
pub id: String,
pub owned_files: Vec<PathBuf>,
pub prerequisites: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub estimated_effort_hours: Option<f32>,
#[serde(default)]
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum DeliverableStatus {
Pending,
Ready,
InProgress,
Complete,
Failed {
reason: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockInfo {
pub plan_id: PlanId,
pub deliverable_id: String,
pub caller_id: CallerId,
pub acquired_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CohortRow {
pub deliverable: Deliverable,
pub lock: LockInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(into = "FlatCohort", try_from = "FlatCohort")]
pub struct Cohort {
pub plan_id: PlanId,
pub rows: Vec<CohortRow>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CohortDecodeError {
pub deliverables: usize,
pub locks: usize,
}
impl std::fmt::Display for CohortDecodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"COHORT_LENGTH_MISMATCH: deliverables ({}) and locks ({}) arrays \
must be the same length; each deliverable is held under exactly one lock",
self.deliverables, self.locks
)
}
}
impl std::error::Error for CohortDecodeError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct FlatCohort {
plan_id: PlanId,
deliverables: Vec<Deliverable>,
locks: Vec<LockInfo>,
}
impl From<Cohort> for FlatCohort {
fn from(cohort: Cohort) -> Self {
let mut deliverables = Vec::with_capacity(cohort.rows.len());
let mut locks = Vec::with_capacity(cohort.rows.len());
for row in cohort.rows {
deliverables.push(row.deliverable);
locks.push(row.lock);
}
FlatCohort {
plan_id: cohort.plan_id,
deliverables,
locks,
}
}
}
impl TryFrom<FlatCohort> for Cohort {
type Error = CohortDecodeError;
fn try_from(flat: FlatCohort) -> Result<Self, Self::Error> {
if flat.deliverables.len() != flat.locks.len() {
return Err(CohortDecodeError {
deliverables: flat.deliverables.len(),
locks: flat.locks.len(),
});
}
let rows = flat
.deliverables
.into_iter()
.zip(flat.locks)
.map(|(deliverable, lock)| CohortRow { deliverable, lock })
.collect();
Ok(Cohort {
plan_id: flat.plan_id,
rows,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanStatus {
pub plan_id: PlanId,
pub deliverables: Vec<(String, DeliverableStatus)>,
pub critical_path: Vec<String>,
pub critical_path_hours: f32,
pub locks_held: Vec<LockInfo>,
}
#[derive(Debug, Error)]
pub enum PlannerError {
#[error("LOCK_HELD: deliverable {deliverable_id} in plan {plan_id} is locked by {holder}")]
LockHeld {
plan_id: String,
deliverable_id: String,
holder: String,
},
#[error("LOCK_NOT_HELD: caller {caller_id} does not hold lock on {deliverable_id}")]
LockNotHeld {
caller_id: String,
deliverable_id: String,
},
#[error("LOCK_EXPIRED: lock on {deliverable_id} expired at {expired_at}")]
LockExpired {
deliverable_id: String,
expired_at: DateTime<Utc>,
},
#[error(
"OVERLAP_DETECTED: deliverable {deliverable_id} owns files {files:?} that overlap with \
currently locked files"
)]
OverlapDetected {
deliverable_id: String,
files: Vec<PathBuf>,
},
#[error(
"MISSING_PREREQUISITE: deliverable {deliverable_id} requires {prereq} which is not \
Complete"
)]
MissingPrerequisite {
deliverable_id: String,
prereq: String,
},
#[error("PLAN_NOT_FOUND: {plan_id}")]
PlanNotFound { plan_id: String },
#[error("DELIVERABLE_NOT_FOUND: {deliverable_id} in plan {plan_id}")]
DeliverableNotFound {
plan_id: String,
deliverable_id: String,
},
#[error("INVALID_GRAPH: {reason}")]
InvalidGraph { reason: String },
#[error("BACKEND_ERROR: {0}")]
BackendError(#[source] anyhow::Error),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plan_graph_serde_roundtrip() -> Result<(), serde_json::Error> {
let graph = PlanGraph {
deliverables: vec![Deliverable {
id: "d1".to_string(),
owned_files: vec![PathBuf::from("src/foo.rs"), PathBuf::from("src/bar.rs")],
prerequisites: vec!["d0".to_string()],
estimated_effort_hours: Some(1.5),
metadata: serde_json::json!({"description": "smoke test"}),
}],
max_chained_dispatch: Some(8),
};
let json = serde_json::to_string(&graph)?;
let back: PlanGraph = serde_json::from_str(&json)?;
assert_eq!(back.deliverables.len(), 1);
let d = &back.deliverables[0];
assert_eq!(d.id, "d1");
assert_eq!(
d.owned_files,
vec![PathBuf::from("src/foo.rs"), PathBuf::from("src/bar.rs")]
);
assert_eq!(d.prerequisites, vec!["d0".to_string()]);
assert_eq!(d.estimated_effort_hours, Some(1.5));
assert_eq!(d.metadata, serde_json::json!({"description": "smoke test"}));
assert_eq!(back.max_chained_dispatch, Some(8));
Ok(())
}
#[test]
fn deliverable_status_failed_carries_reason() -> Result<(), serde_json::Error> {
let status = DeliverableStatus::Failed {
reason: "tests failed".to_string(),
};
let json = serde_json::to_value(&status)?;
assert_eq!(json["status"], "failed");
assert_eq!(json["reason"], "tests failed");
let back: DeliverableStatus = serde_json::from_value(json)?;
assert_eq!(back, status);
Ok(())
}
#[test]
fn cohort_wire_shape_preserves_two_array_layout() -> Result<(), serde_json::Error> {
let plan_id = PlanId("plan_x".to_string());
let now = chrono::Utc::now();
let cohort = Cohort {
plan_id: plan_id.clone(),
rows: vec![
CohortRow {
deliverable: Deliverable {
id: "d1".to_string(),
owned_files: vec![PathBuf::from("a.rs")],
prerequisites: vec![],
estimated_effort_hours: Some(1.0),
metadata: serde_json::Value::Null,
},
lock: LockInfo {
plan_id: plan_id.clone(),
deliverable_id: "d1".to_string(),
caller_id: CallerId("c1".to_string()),
acquired_at: now,
expires_at: now + chrono::Duration::seconds(60),
},
},
CohortRow {
deliverable: Deliverable {
id: "d2".to_string(),
owned_files: vec![PathBuf::from("b.rs")],
prerequisites: vec![],
estimated_effort_hours: Some(2.0),
metadata: serde_json::Value::Null,
},
lock: LockInfo {
plan_id: plan_id.clone(),
deliverable_id: "d2".to_string(),
caller_id: CallerId("c1".to_string()),
acquired_at: now,
expires_at: now + chrono::Duration::seconds(60),
},
},
],
};
let json = serde_json::to_value(&cohort)?;
assert!(json.get("deliverables").is_some());
assert!(json.get("locks").is_some());
assert!(json.get("rows").is_none());
let deliverables = json["deliverables"].as_array().unwrap();
let locks = json["locks"].as_array().unwrap();
assert_eq!(deliverables.len(), 2);
assert_eq!(locks.len(), 2);
assert_eq!(deliverables[0]["id"], "d1");
assert_eq!(locks[0]["deliverable_id"], "d1");
let back: Cohort = serde_json::from_value(json)?;
assert_eq!(back.rows.len(), 2);
assert_eq!(back.rows[0].deliverable.id, "d1");
assert_eq!(back.rows[0].lock.deliverable_id, "d1");
assert_eq!(back.rows[1].deliverable.id, "d2");
assert_eq!(back.rows[1].lock.deliverable_id, "d2");
Ok(())
}
#[test]
fn cohort_rejects_mismatched_deliverables_and_locks_lengths() {
let now = chrono::Utc::now();
let wire = serde_json::json!({
"plan_id": "plan_x",
"deliverables": [
{
"id": "d1",
"owned_files": ["a.rs"],
"prerequisites": [],
"estimated_effort_hours": 1.0,
"metadata": null
},
{
"id": "d2",
"owned_files": ["b.rs"],
"prerequisites": [],
"estimated_effort_hours": 2.0,
"metadata": null
}
],
"locks": [
{
"plan_id": "plan_x",
"deliverable_id": "d1",
"caller_id": "c1",
"acquired_at": now,
"expires_at": now + chrono::Duration::seconds(60)
}
]
});
let err = serde_json::from_value::<Cohort>(wire).unwrap_err();
assert!(
err.to_string().contains("COHORT_LENGTH_MISMATCH"),
"expected COHORT_LENGTH_MISMATCH, got: {err}"
);
}
}