use std::collections::HashMap;
use crate::contract::schema::Ecosystem;
use crate::protocol::journal::{PublishReceipt as JournalReceipt, RunState};
use crate::protocol::plan::{PlanTarget, ReleasePlan};
use crate::protocol::release::{PublishReceipt as AdapterReceipt, VerifyOutcome};
use super::adapters::{resolve, EffectCtx, ReleaseAdapter};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JournalState {
Published,
NotRecorded,
Cancelled,
Delegated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResumeAction {
Skip,
AdoptForward,
ResumePublish,
Conflict,
Unverifiable,
Cancelled,
Delegated,
}
impl ResumeAction {
#[must_use]
pub fn is_blocker(self) -> bool {
matches!(self, Self::Conflict | Self::Unverifiable | Self::Cancelled)
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Skip => "skip",
Self::AdoptForward => "adopt_forward",
Self::ResumePublish => "resume_publish",
Self::Conflict => "conflict",
Self::Unverifiable => "unverifiable",
Self::Cancelled => "cancelled",
Self::Delegated => "delegated",
}
}
}
#[derive(Debug, Clone)]
pub struct TargetDecision {
pub target: String,
pub ecosystem: Ecosystem,
pub journal_state: JournalState,
pub outcome: VerifyOutcome,
pub action: ResumeAction,
pub detail: Option<String>,
pub adopted_receipt: Option<JournalReceipt>,
}
#[derive(Debug, Clone)]
pub struct ResumeReconcile {
pub run_id: String,
pub plan_id: String,
pub decisions: Vec<TargetDecision>,
}
impl ResumeReconcile {
#[must_use]
pub fn blockers(&self) -> Vec<&TargetDecision> {
self.decisions
.iter()
.filter(|d| d.action.is_blocker())
.collect()
}
#[must_use]
pub fn is_blocked(&self) -> bool {
self.decisions.iter().any(|d| d.action.is_blocker())
}
#[must_use]
pub fn adoptions(&self) -> Vec<(&str, &JournalReceipt)> {
self.decisions
.iter()
.filter_map(|d| d.adopted_receipt.as_ref().map(|r| (d.target.as_str(), r)))
.collect()
}
}
#[must_use]
pub fn reconcile_for_resume(
state: &RunState,
plan: &ReleasePlan,
ctx: &EffectCtx<'_>,
allow_unverified: bool,
) -> ResumeReconcile {
let published_report = super::reconcile::reconcile(state, ctx);
let published: HashMap<&str, (VerifyOutcome, Option<String>)> = published_report
.targets
.iter()
.map(|t| (t.target.as_str(), (t.outcome, t.detail.clone())))
.collect();
let target_ids = super::journal_target_ids(&plan.targets);
let mut decisions = Vec::with_capacity(plan.targets.len());
for (pt, target) in plan.targets.iter().zip(target_ids) {
if let Some(reason) = state.cancelled.get(&target) {
decisions.push(TargetDecision {
target,
ecosystem: pt.ecosystem,
journal_state: JournalState::Cancelled,
outcome: VerifyOutcome::Unknown,
action: ResumeAction::Cancelled,
detail: Some(format!(
"this target was cancelled in the original run ({reason}); resuming would \
re-publish it. ossctl will not silently un-cancel a target — abandon and \
re-plan, or reconcile it by hand"
)),
adopted_receipt: None,
});
continue;
}
if state.delegated.contains(&target) || resolve(pt.adapter).is_ci_delegated() {
decisions.push(TargetDecision {
target,
ecosystem: pt.ecosystem,
journal_state: JournalState::Delegated,
outcome: VerifyOutcome::Unknown,
action: ResumeAction::Delegated,
detail: Some(
"this target is produced by the tag-triggered CI (delegated), not the \
engine; there is nothing to resume"
.to_string(),
),
adopted_receipt: None,
});
continue;
}
let (journal_state, outcome, verify_detail) = if state.published.contains_key(&target) {
let (outcome, detail) = published.get(target.as_str()).cloned().unwrap_or((
VerifyOutcome::Unknown,
Some("the published receipt could not be reconciled against the registry".into()),
));
(JournalState::Published, outcome, detail)
} else {
let (outcome, detail) = verify_not_recorded(ctx, pt, &plan.version);
(JournalState::NotRecorded, outcome, detail)
};
let action = classify(journal_state, outcome, allow_unverified);
let adopted_receipt = (action == ResumeAction::AdoptForward).then(|| JournalReceipt {
ecosystem: pt.ecosystem.as_str().to_string(),
package: pt.package.clone(),
version: plan.version.clone(),
registry_url: None,
digest: None,
});
decisions.push(TargetDecision {
detail: action_detail(action, outcome, journal_state, verify_detail),
target,
ecosystem: pt.ecosystem,
journal_state,
outcome,
action,
adopted_receipt,
});
}
ResumeReconcile {
run_id: state.run_id.clone(),
plan_id: state.plan_id.clone(),
decisions,
}
}
#[allow(clippy::match_same_arms)]
fn classify(
journal_state: JournalState,
outcome: VerifyOutcome,
allow_unverified: bool,
) -> ResumeAction {
use JournalState::{Cancelled, Delegated, NotRecorded, Published};
use VerifyOutcome::{Conflicts, Matches, Missing, Unknown};
match (journal_state, outcome) {
(Cancelled, _) => ResumeAction::Cancelled,
(Delegated, _) => ResumeAction::Delegated,
(Published, Matches) => ResumeAction::Skip,
(Published, Conflicts | Missing) => ResumeAction::Conflict,
(Published, Unknown) => {
if allow_unverified {
ResumeAction::Skip
} else {
ResumeAction::Unverifiable
}
}
(NotRecorded, Matches) => ResumeAction::AdoptForward,
(NotRecorded, Missing) => ResumeAction::ResumePublish,
(NotRecorded, Conflicts) => ResumeAction::Conflict,
(NotRecorded, Unknown) => {
if allow_unverified {
ResumeAction::ResumePublish
} else {
ResumeAction::Unverifiable
}
}
}
}
fn verify_not_recorded(
ctx: &EffectCtx<'_>,
pt: &PlanTarget,
version: &str,
) -> (VerifyOutcome, Option<String>) {
let Some(package) = pt.package.clone() else {
return (
VerifyOutcome::Unknown,
Some(
"the plan target has no resolved package name; the registry cannot be queried"
.to_string(),
),
);
};
let receipt = AdapterReceipt {
adapter: pt.adapter,
ecosystem: pt.ecosystem,
package,
version: version.to_string(),
canonical_ref: String::new(),
digest: None,
remote_url: None,
timestamp: 0,
};
let outcome = resolve(pt.adapter)
.verify(ctx, &receipt)
.unwrap_or(VerifyOutcome::Unknown);
(outcome, verify_reason(outcome, pt.ecosystem))
}
fn verify_reason(outcome: VerifyOutcome, ecosystem: Ecosystem) -> Option<String> {
match outcome {
VerifyOutcome::Matches => None,
VerifyOutcome::Missing => {
Some("the registry does not report this version as published".to_string())
}
VerifyOutcome::Conflicts => {
Some("the registry holds this version but its digest differs from the plan".to_string())
}
VerifyOutcome::Unknown if ecosystem == Ecosystem::Binary => Some(
"this distribution target (GitHub Releases or a homebrew formula) is not \
observable through the registry query"
.to_string(),
),
VerifyOutcome::Unknown => Some(
"the registry lookup could not be performed (registry outage or unresolvable package)"
.to_string(),
),
}
}
fn action_detail(
action: ResumeAction,
outcome: VerifyOutcome,
journal_state: JournalState,
verify_detail: Option<String>,
) -> Option<String> {
match action {
ResumeAction::Skip | ResumeAction::Cancelled | ResumeAction::Delegated => None,
ResumeAction::AdoptForward => Some(
"a publish landed before its receipt was recorded; adopting it forward so it is \
not re-published"
.to_string(),
),
ResumeAction::ResumePublish => Some(match journal_state {
JournalState::NotRecorded if outcome == VerifyOutcome::Unknown => {
"unverifiable and not recorded as published; resuming the publish under the \
explicit go-ahead"
.to_string()
}
_ => "not published; resuming the publish for this target".to_string(),
}),
ResumeAction::Conflict => Some(match outcome {
VerifyOutcome::Conflicts => {
"a different artifact is published at this version — a human must reconcile \
before resuming; ossctl will not overwrite it"
.to_string()
}
VerifyOutcome::Missing => {
"this run recorded a publish the registry no longer reports (deleted or \
transient) — a human must decide; ossctl will not blindly re-publish"
.to_string()
}
_ => verify_detail.unwrap_or_else(|| "conflicting registry state".to_string()),
}),
ResumeAction::Unverifiable => Some(verify_detail.map_or_else(
|| {
"the reconcile could not be performed; pass --allow-unverified to proceed on trust"
.to_string()
},
|d| format!("{d}; pass --allow-unverified to proceed on trust"),
)),
}
}
#[cfg(test)]
mod tests;