use anyhow::{bail, Result};
use serde::Serialize;
use crate::content_trust::ContentTrust;
use crate::paths::state::StateLayout;
use super::{BacklogRef, StartupContext, WorkflowExtension};
#[derive(Clone, Debug)]
pub(crate) enum AssignmentOwner<'a> {
Session {
session_id: &'a str,
branch: Option<&'a str>,
},
PreSessionBranch {
branch: &'a str,
},
}
#[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>,
}
#[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,
}
#[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>,
}
pub(crate) struct SessionBoundaryContext<'a> {
pub layout: &'a StateLayout,
pub locality_id: &'a str,
pub session_id: &'a str,
}
#[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,
}
#[derive(Clone, Debug, Serialize)]
pub(crate) struct ExtensionStartupPayload {
pub next_step: ExtensionNextStepView,
#[serde(skip_serializing_if = "Option::is_none")]
pub assignment: Option<AssignmentOutcome>,
}
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 resolve_active_assignment(
ctx: &StartupContext<'_>,
active_session_id: Option<&str>,
branch: Option<&str>,
) -> Result<Option<AssignmentView>> {
if let Some(session_id) = active_session_id {
if let Some(assignment) = load_session_assignment(ctx, session_id)? {
return Ok(Some(assignment));
}
}
match branch {
Some(branch) if !branch.is_empty() => load_branch_assignment(ctx, branch),
_ => 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()),
}
}
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,
};
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\""));
}
}