diff --git a/apps/server/src/auto_deploy.rs b/apps/server/src/auto_deploy.rs index 7a777a0e2..a0f8fa200 100644 --- a/apps/server/src/auto_deploy.rs +++ b/apps/server/src/auto_deploy.rs @@ -166,8 +166,10 @@ pub(crate) async fn deploy_project( target_sha: Option<&str>, ) { // Une correction de Rustine est en cours en local : pas de remise à zéro du dossier de travail. - if crate::correction::has_open_repair(&state.pool, &project.uuid).await { - tracing::info!(project = %project.name, "auto-deploy: correction en cours, déploiement reporté"); + // Seule une réparation en cours de test récente retient le commit ; une réparation + // « prête » jamais appliquée ne bloque plus l'auto-deploy (incident du 14/10/2026). + if crate::correction::gate_deploy_on_repair(&state.pool, &project.uuid).await { + tracing::warn!(project = %project.name, "auto-deploy: réparation en test, déploiement reporté"); return; } let dep_uuid = new_uuid(); diff --git a/apps/server/src/correction.rs b/apps/server/src/correction.rs index 44d7e57e5..82433ff4e 100644 --- a/apps/server/src/correction.rs +++ b/apps/server/src/correction.rs @@ -503,16 +503,88 @@ pub async fn ensure_repair_agent(pool: &PgPool, project_uuid: &str) -> Result bool { - sqlx::query_as::<_, (i64,)>( - "SELECT COUNT(*) FROM corrections WHERE project_uuid = $1 AND kind = 'repair' AND state IN ('testing', 'proposed', 'conflict')", +/// Durée maximale pendant laquelle une réparation « en test » retarde un nouveau commit. +pub const REPAIR_TESTING_HOLD_MIN: i64 = 30; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RepairGate { + /// Rustine teste activement sa correction : on reporte (au prochain tick). + Wait, + /// Réparation prête / en conflit / test bloqué : le nouveau commit passe devant, + /// le brouillon est sauvegardé puis la réparation est archivée. + Supersede, +} + +/// Décision pure : seule une réparation en cours de test récente retient un nouveau commit. +/// Avant (incident du 14/10/2026), une réparation « prête » jamais appliquée bloquait +/// silencieusement tout auto-deploy du projet, indéfiniment. +pub fn repair_gate( + state: &str, + updated_at: &str, + now: chrono::DateTime, +) -> RepairGate { + if state != "testing" { + return RepairGate::Supersede; + } + match chrono::DateTime::parse_from_rfc3339(updated_at) { + Ok(t) if (now - t.with_timezone(&chrono::Utc)).num_minutes() < REPAIR_TESTING_HOLD_MIN => { + RepairGate::Wait + } + _ => RepairGate::Supersede, + } +} + +/// Avant un déploiement d'un nouveau commit : `true` = reporter. Sinon les réparations +/// ouvertes sont archivées (le brouillon est sauvegardé par l'appelant avant le reset). +pub async fn gate_deploy_on_repair(pool: &PgPool, project_uuid: &str) -> bool { + let rows: Vec<(String, String, String)> = sqlx::query_as( + "SELECT uuid, state, updated_at FROM corrections WHERE project_uuid = $1 AND kind = 'repair' AND state IN ('testing', 'proposed', 'conflict')", ) .bind(project_uuid) - .fetch_one(pool) + .fetch_all(pool) .await - .map(|(n,)| n > 0) - .unwrap_or(false) + .unwrap_or_default(); + let now = chrono::Utc::now(); + if rows + .iter() + .any(|(_, st, up)| repair_gate(st, up, now) == RepairGate::Wait) + { + return true; + } + for (uuid, _, _) in rows { + let _ = sqlx::query("UPDATE corrections SET state = 'archived', note = 'Remplacée : un nouveau commit a été mis en ligne (brouillon sauvegardé).', updated_at = $1 WHERE uuid = $2") + .bind(now_str()) + .bind(&uuid) + .execute(pool) + .await; + } + false +} + +#[cfg(test)] +mod repair_gate_tests { + use super::*; + + #[test] + fn proposed_or_conflict_never_blocks() { + let now = chrono::Utc::now(); + let t = now.to_rfc3339(); + assert_eq!(repair_gate("proposed", &t, now), RepairGate::Supersede); + assert_eq!(repair_gate("conflict", &t, now), RepairGate::Supersede); + } + + #[test] + fn testing_blocks_only_while_recent() { + let now = chrono::Utc::now(); + let recent = (now - chrono::Duration::minutes(5)).to_rfc3339(); + let stale = (now - chrono::Duration::minutes(REPAIR_TESTING_HOLD_MIN + 1)).to_rfc3339(); + assert_eq!(repair_gate("testing", &recent, now), RepairGate::Wait); + assert_eq!(repair_gate("testing", &stale, now), RepairGate::Supersede); + assert_eq!( + repair_gate("testing", "garbage", now), + RepairGate::Supersede + ); + } } /// Échec dû à la plateforme (délais Docker, conteneur jamais prêt sans erreur de l'app).