cpm-planner 0.2.2

Critical Path Method planner as an MCP server: schedules task graphs (slack, critical path, resource levelling, Monte Carlo), leases lock-aware parallel cohorts to agents, versions plan-as-code variants, and tracks earned value.
Documentation
//! Earned-value operations of [`BasicCpmPlanner`] behind
//! [`crate::ports::Planner::baseline`], [`crate::ports::Planner::ev`] and
//! [`crate::ports::Planner::snapshot`] (`plan.baseline`, `plan.ev`,
//! `plan.snapshot`; P5).
//!
//! Gating: taking a baseline and appending a snapshot are execution-side
//! writes, so both refuse a draft variant (`VARIANT_NOT_SELECTED`) and an
//! archived one (`ARCHIVE_REFUSED`), like `acquire_cohort`. Reading the
//! report is read-only and works on any plan, including drafts and archived
//! variants, as long as it has a baseline (`NOT_BASELINED` otherwise).
//!
//! Baselines, actuals and snapshots belong to one plan id, so each variant
//! has its own: a newly selected variant takes baseline 1 (no reason
//! needed) and starts its own snapshot history.
//!
//! The export is read bounded ([`ev_store::snapshots`]: the newest 100 by
//! `as_of`, ties by `taken_at`) and kept in that order, so a backfilled
//! snapshot lands in date order. Alert windows are their own bounded,
//! per-baseline query ([`ev_store::alert_window`]): a snapshot's alerts
//! see only readings at or before its own `(as_of, taken_at)`, and never
//! backfilled ones.
//!
//! `as_of` is the PV status date only: EV and AC are read from the
//! statuses and actuals stored when the call runs. A snapshot whose `as_of`
//! is more than an hour before its `taken_at` is flagged `backfilled` and
//! raises no alerts of its own.

use std::collections::HashMap;

use chrono::{DateTime, Utc};
use rusqlite::Transaction;
use serde_json::json;

use super::BasicCpmPlanner;
use crate::audit::AuditEvent;
use crate::earned_value::{
    BaselineOutcome, BaselineRequest, EarningRule, EvReport, EvSummary, MAX_BASELINE_REASON_CHARS,
    SNAPSHOT_EXPORT_LIMIT, SnapshotFormat, SnapshotOutcome, SnapshotRequest, SnapshotSummary,
    build_baseline, compute_ev, render_snapshots_markdown, trend_alerts,
};
use crate::ev_store;
use crate::locks::PlanState;
use crate::plan::{PlanGraph, PlanId, PlannerError};
use crate::plan_store::{backend, load_plan_state};

fn load_state(tx: &Transaction<'_>, plan_id: &PlanId) -> Result<PlanState, PlannerError> {
    load_plan_state(tx, plan_id)?.ok_or_else(|| PlannerError::PlanNotFound {
        plan_id: plan_id.0.clone(),
    })
}

fn not_baselined(plan_id: &PlanId) -> PlannerError {
    PlannerError::NotBaselined {
        plan_id: plan_id.0.clone(),
    }
}

/// Decode stored snapshot summaries, keeping their order.
fn decode(
    plan_id: &PlanId,
    rows: Vec<ev_store::EvSnapshot>,
) -> Result<Vec<SnapshotSummary>, PlannerError> {
    rows.into_iter()
        .map(|s| {
            serde_json::from_value(s.summary).map_err(|e| {
                backend(anyhow::anyhow!(
                    "plan {plan_id}: stored snapshot taken at {} does not decode: {e}",
                    s.taken_at
                ))
            })
        })
        .collect()
}

/// The newest stored snapshot summaries of `plan_id`, in `as_of` order
/// (ties by `taken_at`).
fn history(tx: &Transaction<'_>, plan_id: &PlanId) -> Result<Vec<SnapshotSummary>, PlannerError> {
    decode(plan_id, ev_store::snapshots(tx, plan_id)?)
}

/// Ratios of the latest `limit` snapshots of baseline `number` at or
/// before `position` (unbounded when `None`), oldest first.
fn alert_ratios(
    tx: &Transaction<'_>,
    plan_id: &PlanId,
    number: u32,
    position: Option<(DateTime<Utc>, DateTime<Utc>)>,
    limit: usize,
) -> Result<Vec<EvSummary>, PlannerError> {
    let rows = ev_store::alert_window(tx, plan_id, number, position, limit)?;
    Ok(decode(plan_id, rows)?
        .iter()
        .map(SnapshotSummary::ratios)
        .collect())
}

fn rules_of(graph: &PlanGraph) -> HashMap<String, EarningRule> {
    graph
        .deliverables
        .iter()
        .map(|d| (d.id.clone(), d.earning_rule.unwrap_or_default()))
        .collect()
}

/// Normalise a baseline reason: trimmed, blank means absent, and at most
/// [`MAX_BASELINE_REASON_CHARS`] characters.
fn normalise_reason(reason: Option<&str>) -> Result<Option<String>, PlannerError> {
    let Some(reason) = reason else {
        return Ok(None);
    };
    if reason.chars().count() > MAX_BASELINE_REASON_CHARS {
        return Err(PlannerError::InvalidGraph {
            reason: format!(
                "baseline reason is longer than {MAX_BASELINE_REASON_CHARS} characters"
            ),
        });
    }
    let trimmed = reason.trim();
    Ok((!trimmed.is_empty()).then(|| trimmed.to_string()))
}

impl BasicCpmPlanner {
    /// Freeze the plan's current CPM schedule and budgets as its next
    /// baseline (`plan.baseline`). The first baseline is number 1; a
    /// re-baseline takes the next number and needs a non-blank `reason`
    /// (`INVALID_GRAPH` otherwise). Actuals and snapshots are kept: only
    /// the PV curve and budgets change. `start` defaults to the clock's now.
    /// `calendar` defaults to wall-clock hours on the first baseline and to
    /// the previous baseline's calendar on a re-baseline.
    /// Gated like execution (selected, unarchived variant only). Audited as
    /// `plan.ev.baselined`.
    pub(super) async fn take_baseline(
        &self,
        req: BaselineRequest,
    ) -> Result<BaselineOutcome, PlannerError> {
        let reason = normalise_reason(req.reason.as_deref())?;
        let now = self.now();
        let start = req.start.unwrap_or(now);
        let plan_id = req.plan_id;
        let (outcome, previous) = self.store.write_tx(|tx| {
            crate::portfolio::ensure_executable(tx, &plan_id)?;
            let state = load_state(tx, &plan_id)?;
            let latest = ev_store::latest_baseline(tx, &plan_id)?;
            let previous = latest.as_ref().map(|b| b.baseline.number);
            // A re-baseline without a calendar keeps the previous one.
            let calendar = req
                .calendar
                .or_else(|| latest.and_then(|b| b.baseline.calendar));
            let number = match previous {
                None => 1,
                Some(n) if reason.is_none() => {
                    return Err(PlannerError::InvalidGraph {
                        reason: format!(
                            "plan {plan_id} already has baseline {n}; re-baselining requires a \
                             non-empty reason"
                        ),
                    });
                }
                Some(n) => n.checked_add(1).ok_or_else(|| PlannerError::InvalidGraph {
                    reason: format!("plan {plan_id} has used every baseline number"),
                })?,
            };
            let baseline =
                build_baseline(&state.graph, &state.cached_result, start, calendar, number)?;
            ev_store::insert_baseline(tx, &plan_id, &baseline, reason.as_deref(), now)?;
            let outcome = BaselineOutcome {
                plan_id: plan_id.0.clone(),
                baseline_number: baseline.number,
                start: baseline.start,
                calendar: baseline.calendar,
                bac: baseline.bac,
                finish_hours: baseline.finish_hours,
                deliverable_count: baseline.rows.len(),
                reason: reason.clone(),
            };
            Ok((outcome, previous))
        })?;
        let event = AuditEvent::new("plan.ev.baselined").with_payload(json!({
            "plan_id": outcome.plan_id,
            "baseline_number": outcome.baseline_number,
            "previous_baseline": previous,
            "start": outcome.start,
            "bac": outcome.bac,
            "finish_hours": outcome.finish_hours,
            "deliverable_count": outcome.deliverable_count,
            "reason": outcome.reason,
        }));
        self.flush_audit(vec![event]).await;
        Ok(outcome)
    }

    /// The earned-value report of `plan_id` against its latest baseline
    /// (`plan.ev`), as of `as_of` (default: the clock's now). Read-only and
    /// ungated. `as_of` sets PV only; EV and AC are the progress stored
    /// now. Trend alerts look at the two latest stored non-backfilled
    /// snapshots (by `as_of`) of the latest baseline; the current reading is
    /// not one of them. `NOT_BASELINED` before the first baseline.
    ///
    /// Blocking: the report recomputes the CPM, so async callers should run
    /// it off the runtime's worker threads (the MCP server does).
    pub(super) fn ev_report(
        &self,
        plan_id: &PlanId,
        as_of: Option<DateTime<Utc>>,
    ) -> Result<EvReport, PlannerError> {
        let as_of = as_of.unwrap_or_else(|| self.now());
        let (graph, statuses, baseline, actuals, ratios) = self.store.read_tx(|tx| {
            let state = load_state(tx, plan_id)?;
            let baseline = ev_store::latest_baseline(tx, plan_id)?
                .ok_or_else(|| not_baselined(plan_id))?
                .baseline;
            let actuals = ev_store::load_actuals(tx, plan_id)?;
            let ratios = alert_ratios(tx, plan_id, baseline.number, None, 2)?;
            Ok((state.graph, state.statuses, baseline, actuals, ratios))
        })?;
        compute_ev(
            &baseline,
            &graph,
            &statuses,
            &rules_of(&graph),
            &actuals,
            as_of,
            &ratios,
        )
    }

    /// Compute the report (as for [`Self::ev_report`]) and append it as a
    /// snapshot (`plan.snapshot`). Its alerts look only at readings up to
    /// its own position: this snapshot and the latest earlier one, by
    /// `(as_of, taken_at)`, of the current baseline, so a backfill never
    /// takes alerts from newer readings.
    /// `taken_at` is the clock's now, nudged one microsecond past the
    /// latest stored `taken_at` when the clock has not moved past it, so
    /// snapshots stay distinct. Gated like execution. Returns the summary,
    /// the stored count and the newest [`SNAPSHOT_EXPORT_LIMIT`] snapshots
    /// by `as_of`, rendered in `format`. `NOT_BASELINED` before the first
    /// baseline.
    ///
    /// Blocking, like [`Self::ev_report`].
    pub(super) fn take_snapshot(
        &self,
        req: SnapshotRequest,
    ) -> Result<SnapshotOutcome, PlannerError> {
        let now = self.now();
        let as_of = req.as_of.unwrap_or(now);
        let plan_id = &req.plan_id;
        self.store.write_tx(|tx| {
            crate::portfolio::ensure_executable(tx, plan_id)?;
            let state = load_state(tx, plan_id)?;
            let baseline = ev_store::latest_baseline(tx, plan_id)?
                .ok_or_else(|| not_baselined(plan_id))?
                .baseline;
            let actuals = ev_store::load_actuals(tx, plan_id)?;
            let taken_at = match ev_store::latest_taken_at(tx, plan_id)? {
                Some(last) if now <= last => last + chrono::Duration::microseconds(1),
                _ => now,
            };
            let mut ratios =
                alert_ratios(tx, plan_id, baseline.number, Some((as_of, taken_at)), 1)?;
            let report = compute_ev(
                &baseline,
                &state.graph,
                &state.statuses,
                &rules_of(&state.graph),
                &actuals,
                as_of,
                &ratios,
            )?;
            let mut summary = SnapshotSummary::from_report(&report, taken_at);
            ratios.push(summary.ratios());
            summary.alerts = trend_alerts(&ratios, baseline.number);
            let stored = serde_json::to_value(&summary).map_err(backend)?;
            ev_store::insert_snapshot(tx, plan_id, taken_at, as_of, &stored)?;
            let history = history(tx, plan_id)?;
            let newest = &history[history.len().saturating_sub(SNAPSHOT_EXPORT_LIMIT)..];
            let export = match req.format {
                SnapshotFormat::Json => serde_json::to_value(newest).map_err(backend)?,
                SnapshotFormat::Markdown => json!(render_snapshots_markdown(newest)),
            };
            Ok(SnapshotOutcome {
                summary,
                format: req.format,
                export,
                snapshot_count: ev_store::snapshot_count(tx, plan_id)?,
            })
        })
    }
}