ccd-cli 1.0.0-alpha.9

Bootstrap and validate Continuous Context Development repositories
use anyhow::{bail, Result};
use serde::Serialize;

use crate::content_trust::ContentTrust;
use crate::paths::state::StateLayout;

use super::{BacklogRef, StartupContext, WorkflowExtension};

// --- Assignment ownership ---

#[derive(Clone, Debug)]
pub(crate) enum AssignmentOwner<'a> {
    Session {
        session_id: &'a str,
        branch: Option<&'a str>,
    },
    PreSessionBranch {
        branch: &'a str,
    },
}

/// Returned by `ensure_assignment` and `load_session_assignment` —
/// mirrors the owner variant so the caller knows whether the claim
/// is session-keyed or pre-session.
#[cfg_attr(not(feature = "extension-backlog"), allow(dead_code))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub(crate) enum AssignmentOwnerView {
    Session { session_id: String },
    PreSessionBranch { branch: String },
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct AssignmentView {
    pub backlog_ref: BacklogRef,
    pub ccd_id: u64,
    pub github_issue_number: u64,
    pub content_trust: ContentTrust,
    pub title: String,
    pub owner: AssignmentOwnerView,
    pub branch: Option<String>,
    pub worktree: String,
}

#[cfg_attr(not(feature = "extension-backlog"), allow(dead_code))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum AssignmentStatus {
    Existing,
    Assigned,
    Skipped,
}

#[derive(Clone, Debug, Serialize)]
pub(crate) struct AssignmentOutcome {
    pub status: AssignmentStatus,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub next_step: Option<ExtensionNextStepView>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub assignment: Option<AssignmentView>,
}

// --- Next-step observations (read-only preview) ---

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct NextStepItem {
    pub backlog_ref: BacklogRef,
    pub ccd_id: u64,
    pub github_issue_number: u64,
    pub content_trust: ContentTrust,
    pub title: String,
    pub branch: Option<String>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum NextStepStatus {
    Observed,
    NeedsInput,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum NextStepConfidence {
    Cached,
    Unverified,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum NextStepSource {
    ActiveAssignment,
    BacklogAdapter,
    ExplicitActorInput,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct NextStepObservation {
    pub item: NextStepItem,
    pub confidence: NextStepConfidence,
}

/// Full extension-owned next-step view for startup reports. Carries the
/// positive path (observation + confidence) and the negative path
/// (needs_input + source + reason) without teaching the kernel to
/// present extension workflow state as canonical next-step truth.
#[derive(Clone, Debug, Serialize)]
pub(crate) struct ExtensionNextStepView {
    pub status: NextStepStatus,
    pub source: NextStepSource,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub observation: Option<NextStepObservation>,
}

// --- Session boundary ---

// Session-bound dispatch context carries only the fields that backlog-capable
// extensions currently consume at session boundaries.
pub(crate) struct SessionBoundaryContext<'a> {
    pub layout: &'a StateLayout,
    pub locality_id: &'a str,
    pub session_id: &'a str,
}

// --- Extension-pushed startup alerts ---

#[derive(Clone, Debug, Serialize)]
pub(crate) struct StartupAlert {
    pub check: &'static str,
    pub severity: StartupAlertSeverity,
    pub message: String,
}

#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum StartupAlertSeverity {
    Warning,
}

// --- Extension-owned startup payload ---

#[derive(Clone, Debug, Serialize)]
pub(crate) struct ExtensionStartupPayload {
    /// Full extension-owned next-step view for startup consumers.
    pub next_step: ExtensionNextStepView,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub assignment: Option<AssignmentOutcome>,
}

// --- Dispatch-owner routing ---

/// Returns the single active dispatch-owning extension, or `None` when
/// no workflow extension is registered.
///
/// **Fail-closed:** if more than one workflow extension is registered,
/// this returns an error rather than silently picking by registration order.
pub(crate) fn active_dispatch_owner() -> Result<Option<&'static dyn WorkflowExtension>> {
    let owners = super::registered_workflow();
    match owners.len() {
        0 => Ok(None),
        1 => Ok(Some(owners[0])),
        n => bail!(
            "multiple workflow extensions ({n}) are registered; \
             exactly one dispatch-owning workflow extension is required"
        ),
    }
}

pub(crate) fn load_session_assignment(
    ctx: &StartupContext<'_>,
    session_id: &str,
) -> Result<Option<AssignmentView>> {
    match active_dispatch_owner()? {
        Some(ext) => ext.load_session_assignment(ctx, session_id),
        None => Ok(None),
    }
}

pub(crate) fn load_branch_assignment(
    ctx: &StartupContext<'_>,
    branch: &str,
) -> Result<Option<AssignmentView>> {
    match active_dispatch_owner()? {
        Some(ext) => ext.load_branch_assignment(ctx, branch),
        None => Ok(None),
    }
}

pub(crate) fn ensure_assignment(
    ctx: &StartupContext<'_>,
    owner: AssignmentOwner<'_>,
) -> Result<AssignmentOutcome> {
    match active_dispatch_owner()? {
        Some(ext) => ext.ensure_assignment(ctx, owner),
        None => Ok(AssignmentOutcome {
            status: AssignmentStatus::Skipped,
            reason: None,
            next_step: None,
            assignment: None,
        }),
    }
}

pub(crate) fn observe_next_step(ctx: &StartupContext<'_>) -> Result<Option<NextStepObservation>> {
    match active_dispatch_owner()? {
        Some(ext) => ext.observe_next_step(ctx),
        None => Ok(None),
    }
}

pub(crate) fn on_session_started(ctx: &SessionBoundaryContext<'_>) -> Result<()> {
    match active_dispatch_owner()? {
        Some(ext) => ext.on_session_started(ctx),
        None => Ok(()),
    }
}

pub(crate) fn on_session_cleared(ctx: &SessionBoundaryContext<'_>) -> Result<()> {
    match active_dispatch_owner()? {
        Some(ext) => ext.on_session_cleared(ctx),
        None => Ok(()),
    }
}

pub(crate) fn resolve_assignment_references(
    ctx: &StartupContext<'_>,
    assignment: &AssignmentView,
) -> Result<Vec<StartupAlert>> {
    match active_dispatch_owner()? {
        Some(ext) => ext.resolve_assignment_references(ctx, assignment),
        None => Ok(Vec::new()),
    }
}

/// Builds the extension-owned startup payload for the start report.
/// The kernel embeds this as an opaque field rather than deriving
/// next-step guidance as kernel logic.
pub(crate) fn build_startup_payload(
    ctx: &StartupContext<'_>,
    assignment_outcome: &AssignmentOutcome,
) -> Result<ExtensionStartupPayload> {
    let has_dispatch_owner = active_dispatch_owner()?.is_some();
    let next_step = match &assignment_outcome.assignment {
        Some(assignment) => {
            let source = match assignment_outcome.status {
                AssignmentStatus::Assigned => NextStepSource::BacklogAdapter,
                _ => NextStepSource::ActiveAssignment,
            };
            // Derive the observed next step directly from the assignment
            // rather than calling observe_next_step(), which reads the
            // extension-owned dispatch store independently and can drift in
            // multi-claim/shared-pod cases.
            let observation = Some(NextStepObservation {
                item: NextStepItem {
                    backlog_ref: assignment.backlog_ref.clone(),
                    ccd_id: assignment.ccd_id,
                    github_issue_number: assignment.github_issue_number,
                    content_trust: assignment.content_trust,
                    title: assignment.title.clone(),
                    branch: assignment.branch.clone(),
                },
                confidence: if ctx.allow_cached_work {
                    NextStepConfidence::Cached
                } else {
                    NextStepConfidence::Unverified
                },
            });
            ExtensionNextStepView {
                status: NextStepStatus::Observed,
                source,
                reason: None,
                observation,
            }
        }
        None => {
            if let Some(next_step) = assignment_outcome.next_step.clone() {
                next_step
            } else {
                match observe_next_step(ctx)? {
                    Some(observation) => ExtensionNextStepView {
                        status: NextStepStatus::Observed,
                        source: NextStepSource::ActiveAssignment,
                        reason: None,
                        observation: Some(observation),
                    },
                    None => ExtensionNextStepView {
                        status: NextStepStatus::NeedsInput,
                        source: if has_dispatch_owner && assignment_outcome.reason.is_some() {
                            NextStepSource::BacklogAdapter
                        } else {
                            NextStepSource::ExplicitActorInput
                        },
                        reason: assignment_outcome.reason.clone().or_else(|| {
                            Some(
                                "no extension-owned next-step observation is available; \
                                 choose the next item explicitly or use a neutral session name"
                                    .to_owned(),
                            )
                        }),
                        observation: None,
                    },
                }
            }
        }
    };

    Ok(ExtensionStartupPayload {
        next_step,
        assignment: Some(assignment_outcome.clone()),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn active_dispatch_owner_returns_backlog_when_enabled() {
        let owner = active_dispatch_owner().unwrap();
        #[cfg(feature = "extension-backlog")]
        assert!(owner.is_some());
        #[cfg(not(feature = "extension-backlog"))]
        assert!(owner.is_none());
    }

    #[test]
    fn no_dispatch_owner_returns_skipped_assignment() {
        let outcome = AssignmentOutcome {
            status: AssignmentStatus::Skipped,
            reason: None,
            next_step: None,
            assignment: None,
        };
        assert_eq!(outcome.status, AssignmentStatus::Skipped);
        assert!(outcome.assignment.is_none());
    }

    #[test]
    fn assignment_owner_view_serializes_with_kind_tag() {
        let session = AssignmentOwnerView::Session {
            session_id: "ses_01".to_owned(),
        };
        let json = serde_json::to_string(&session).unwrap();
        assert!(json.contains("\"kind\":\"session\""));

        let pre = AssignmentOwnerView::PreSessionBranch {
            branch: "main".to_owned(),
        };
        let json = serde_json::to_string(&pre).unwrap();
        assert!(json.contains("\"kind\":\"pre_session_branch\""));
    }
}