diff --git a/crates/ordo-platform/src/release/executions.rs b/crates/ordo-platform/src/release/executions.rs index 91a43876..f3541002 100644 --- a/crates/ordo-platform/src/release/executions.rs +++ b/crates/ordo-platform/src/release/executions.rs @@ -1056,6 +1056,26 @@ async fn build_rollback_context( )) })?; + // Re-inline sub-rules into the target snapshot before dispatching the rollback. + // A direct publish's deployment row stores the un-inlined authored draft, so + // rolling it back verbatim would ship a dangling SubRule reference (silently + // dropped at studio→engine conversion). Inlining is idempotent, so an + // already-inlined snapshot (governed releases) is untouched. A sub-rule that no + // longer exists makes the rollback unassemblable — a permanent failure — so map + // it to SetupDependencyMissing to settle as RollbackFailed instead of looping. + let snapshot = crate::ruleset_draft::inline_sub_rules_into_draft( + &state, + &release.org_id, + &release.project_id, + rollback_deployment.snapshot, + ) + .await + .map_err(|e| { + anyhow::Error::new(SetupDependencyMissing(format!( + "Rollback snapshot could not be assembled: {e}" + ))) + })?; + execution.instances.sort_by(|left, right| { left.batch_index .cmp(&right.batch_index) @@ -1074,7 +1094,7 @@ async fn build_rollback_context( rollback_version, env, instances: execution.instances, - snapshot: rollback_deployment.snapshot, + snapshot, strategy: execution.strategy, actor: system_history_actor("release_worker"), release_note: Some(format!("Rollback for release {}", release_request_id)), diff --git a/crates/ordo-platform/src/ruleset_draft.rs b/crates/ordo-platform/src/ruleset_draft.rs index a07c2b80..af920f5b 100644 --- a/crates/ordo-platform/src/ruleset_draft.rs +++ b/crates/ordo-platform/src/ruleset_draft.rs @@ -704,6 +704,20 @@ pub async fn redeploy( .map_err(PlatformError::Internal)? .ok_or_else(|| PlatformError::not_found("Environment not found"))?; + // Re-inline sub-rules before rolling the snapshot out again. A direct publish + // stores the *authored* (un-inlined) draft in its deployment row, and older + // rows predate inlining entirely, so dispatching the stored snapshot verbatim + // would ship a ruleset whose SubRule steps reference a graph that isn't there + // (studio→engine conversion drops the dangling reference silently). Inlining is + // idempotent — an already-inlined graph is left untouched (see the + // `sub_rules.contains_key` guard in `inline_sub_rules_with_manifest`) — so this + // heals old/direct-publish rows and no-ops on already-inlined ones. Do it before + // creating the row so a now-missing sub-rule fails fast without orphaning a + // `Dispatched` deployment. + let inlined = + inline_sub_rules_into_draft(&state, &org_id, &project_id, original.snapshot.clone()) + .await?; + let dep_id = Uuid::new_v4().to_string(); let deployment = RulesetDeployment { id: dep_id.clone(), @@ -713,7 +727,7 @@ pub async fn redeploy( ruleset_name: ruleset_name.clone(), version: original.version.clone(), release_note: req.release_note.clone(), - snapshot: original.snapshot.clone(), + snapshot: inlined.clone(), deployed_at: Utc::now(), deployed_by: Some(claims.sub.clone()), status: DeploymentStatus::Dispatched, @@ -724,16 +738,16 @@ pub async fn redeploy( .await .map_err(PlatformError::Internal)?; - // The stored deployment snapshot already contains inlined sub-rules — roll it out - // through the release engine, exactly like a fresh publish (async, worker-settled). - // On enqueue failure, settle the `Dispatched` row to `Failed` so it doesn't orphan. + // Roll it out through the release engine, exactly like a fresh publish (async, + // worker-settled). On enqueue failure, settle the `Dispatched` row to `Failed` + // so it doesn't orphan. if let Err(e) = enqueue_publish_release( &state, &org_id, &project_id, &ruleset_name, &original.version, - original.snapshot.clone(), + inlined, &dep_id, &env, &claims,