From a2059b0a894f11aeeb6c7dc686e97bb66652818d Mon Sep 17 00:00:00 2001 From: Mathieu JESER Date: Tue, 13 Oct 2026 01:28:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(P1-10):=20contr=C3=B4le=20de=20changement?= =?UTF-8?q?=20avant=20tout=20r=C3=A9veil=20d'agent=20+=20journal=20des=20r?= =?UTF-8?q?=C3=A9veils=20+=20ex=C3=A9cutions=20non=20surveill=C3=A9es=20en?= =?UTF-8?q?=20brouillon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/agent_runs.rs | 59 +- apps/server/src/auto_deploy.rs | 8 +- apps/server/src/auto_deploy_guard.rs | 190 ++++- apps/server/src/db.rs | 9 +- apps/server/src/deploy_queue.rs | 22 +- apps/server/src/dev_env.rs | 57 +- apps/server/src/dns.rs | 6 +- apps/server/src/draft.rs | 428 ++++++++++-- apps/server/src/draft_origin.rs | 120 +++- apps/server/src/draft_routes.rs | 210 ++++-- apps/server/src/draft_tests.rs | 242 ++++++- apps/server/src/git_routes.rs | 21 +- apps/server/src/infra_routes.rs | 78 +-- apps/server/src/llm_routes.rs | 21 +- apps/server/src/main.rs | 8 +- apps/server/src/mcp_routes.rs | 20 +- apps/server/src/memory_routes.rs | 234 +++++-- apps/server/src/mobile_routes.rs | 30 +- apps/server/src/mobile_routes_tests.rs | 30 +- apps/server/src/project_oidc.rs | 8 +- apps/server/src/project_pg.rs | 4 +- apps/server/src/rich_chat.rs | 414 ++++++++--- apps/server/src/routes.rs | 310 +++++++-- apps/server/src/run_events.rs | 178 ++++- apps/server/src/run_routes.rs | 223 ++++-- apps/server/src/spec_routes.rs | 10 +- apps/server/src/state.rs | 118 +++- apps/server/src/wake_causes.rs | 142 +++- apps/server/src/wake_gate.rs | 649 ++++++++++++++++++ apps/web/src/components/ProjectDetailPage.tsx | 2 + apps/web/src/components/WakeLogPanel.test.tsx | 30 + apps/web/src/components/WakeLogPanel.tsx | 84 +++ apps/web/src/lib/api.ts | 17 + crates/agent/src/claims.rs | 12 +- crates/agent/src/coerce.rs | 39 +- crates/agent/src/events.rs | 279 ++++++-- crates/agent/src/guard.rs | 195 +++++- crates/agent/src/lib.rs | 555 +++++++++++---- crates/agent/src/loop_tests.rs | 569 ++++++++++++--- crates/agent/src/memory_ctx.rs | 4 +- crates/agent/src/roles.rs | 60 +- crates/agent/src/scaffold_chain_test.rs | 19 +- crates/agent/src/sdd.rs | 29 +- crates/agent/src/tools/agent_threads.rs | 97 +-- crates/agent/src/tools/ask_user.rs | 77 ++- crates/agent/src/tools/delegate.rs | 49 +- crates/agent/src/tools/deploy.rs | 12 +- crates/agent/src/tools/env.rs | 23 +- crates/agent/src/tools/github.rs | 29 +- crates/agent/src/tools/github_ops.rs | 12 +- crates/agent/src/tools/github_repo.rs | 36 +- crates/agent/src/tools/http.rs | 45 +- crates/agent/src/tools/mcp.rs | 4 +- crates/agent/src/tools/memory.rs | 152 +++- crates/agent/src/tools/mod.rs | 24 +- crates/agent/src/tools/preview.rs | 104 +-- crates/agent/src/tools/project_files.rs | 61 +- crates/agent/src/tools/publish.rs | 47 +- crates/agent/src/tools/routines.rs | 95 ++- crates/agent/src/tools/sdd.rs | 10 +- crates/agent/src/tools/security.rs | 98 ++- crates/agent/src/tools/sync_workdir.rs | 48 +- crates/agent/src/tools/workdir_cmd.rs | 58 +- crates/agent/src/truncate.rs | 103 ++- crates/backup/src/lib.rs | 55 +- crates/backup/src/scheduler.rs | 5 +- crates/backup/tests/retention_tests.rs | 39 +- crates/cluster/src/client.rs | 4 +- crates/cluster/src/discovery.rs | 5 +- crates/cron/src/models.rs | 19 +- crates/cron/src/scheduler.rs | 66 +- crates/cron/src/service.rs | 40 +- crates/database/src/pg_instance.rs | 10 +- crates/database/src/sqlite_to_pg.rs | 15 +- crates/deploy/src/docker.rs | 47 +- crates/deploy/src/lib.rs | 442 ++++++------ crates/detect/src/lib.rs | 65 +- crates/domain/src/cloudflare.rs | 99 +-- crates/domain/src/lib.rs | 14 +- crates/domain/src/porkbun.rs | 33 +- crates/env/src/lib.rs | 35 +- crates/github/src/http.rs | 39 +- crates/github/src/lib.rs | 38 +- crates/llm/src/errors.rs | 126 +++- crates/llm/src/health.rs | 18 +- crates/llm/src/lib.rs | 56 +- crates/llm/src/openai.rs | 244 +++++-- crates/llm/src/resilient.rs | 153 ++++- crates/llm/src/sse.rs | 33 +- crates/llm/src/stub.rs | 25 +- crates/mcp/src/http_client.rs | 27 +- crates/mcp/src/lib.rs | 32 +- crates/mcp/src/oauth.rs | 39 +- crates/mcp/src/smoke_test.rs | 118 ++-- crates/memory/src/lib.rs | 32 +- crates/memory/src/rules.rs | 147 +++- crates/memory/src/secrets.rs | 90 ++- crates/memory/src/store.rs | 275 ++++++-- crates/memory/tests/store_tests.rs | 346 ++++++++-- crates/proxy/src/lib.rs | 150 ++-- crates/storage/src/lib.rs | 21 +- crates/update/src/lib.rs | 7 +- crates/wireguard/src/lib.rs | 21 +- 103 files changed, 7515 insertions(+), 2442 deletions(-) create mode 100644 apps/server/src/wake_gate.rs create mode 100644 apps/web/src/components/WakeLogPanel.test.tsx create mode 100644 apps/web/src/components/WakeLogPanel.tsx diff --git a/apps/server/src/agent_runs.rs b/apps/server/src/agent_runs.rs index 3be5c01b6f..f83a9d3ac8 100644 --- a/apps/server/src/agent_runs.rs +++ b/apps/server/src/agent_runs.rs @@ -27,7 +27,11 @@ pub struct UserTurnExtras { impl Default for UserTurnExtras { fn default() -> Self { - Self { tool_calls_json: "[]".into(), attachments_json: "[]".into(), reply_to_uuid: None } + Self { + tool_calls_json: "[]".into(), + attachments_json: "[]".into(), + reply_to_uuid: None, + } } } @@ -37,7 +41,14 @@ pub async fn record_user_turn( agent_uuid: &str, content: &str, ) -> Result { - record_user_turn_with(pool, project_uuid, agent_uuid, content, &UserTurnExtras::default()).await + record_user_turn_with( + pool, + project_uuid, + agent_uuid, + content, + &UserTurnExtras::default(), + ) + .await } pub async fn record_user_turn_with( @@ -186,7 +197,17 @@ pub async fn save_assistant_and_finish( tools_json: &str, provider: &str, ) -> Result<(), sqlx::Error> { - save_assistant_and_finish_as(pool, run_uuid, project_uuid, agent_uuid, reply, tools_json, provider, "completed").await + save_assistant_and_finish_as( + pool, + run_uuid, + project_uuid, + agent_uuid, + reply, + tools_json, + provider, + "completed", + ) + .await } /// Fin de tour avec un statut explicite (`completed` | `cancelled`, P4-05). @@ -247,7 +268,11 @@ pub async fn fail_run(pool: &PgPool, run_uuid: &str, agent_uuid: &str, error: &s pub fn summary_line(reply: &str) -> String { let line = reply .lines() - .map(|l| l.trim().trim_start_matches(['#', '*', '-', '>', ' ']).trim()) + .map(|l| { + l.trim() + .trim_start_matches(['#', '*', '-', '>', ' ']) + .trim() + }) .find(|l| !l.is_empty()) .unwrap_or(""); clip(line, 160) @@ -263,12 +288,13 @@ pub async fn enqueue_with( title: Option<&str>, ) -> Result { let uuid = enqueue(pool, project_uuid, agent_uuid, message_uuid).await?; - let _ = sqlx::query("UPDATE agent_runs SET kind = $1, title = COALESCE(title, $2) WHERE uuid = $3") - .bind(kind) - .bind(title.map(|t| clip(t, 80))) - .bind(&uuid) - .execute(pool) - .await; + let _ = + sqlx::query("UPDATE agent_runs SET kind = $1, title = COALESCE(title, $2) WHERE uuid = $3") + .bind(kind) + .bind(title.map(|t| clip(t, 80))) + .bind(&uuid) + .execute(pool) + .await; Ok(uuid) } @@ -278,7 +304,14 @@ pub fn wake_title(marker: &str, content: &str) -> String { if !m.is_empty() { return clip(m, 80); } - clip(content.lines().find(|l| !l.trim().is_empty()).unwrap_or("Réveil").trim(), 80) + clip( + content + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or("Réveil") + .trim(), + 80, + ) } /// Prochain worker en file d'attente du projet, passé en `pending` (P4-03). @@ -360,7 +393,9 @@ pub fn deploy_repair_followup(user_message: &str, tools_json: &str) -> Option = serde_json::from_str(tools_json).unwrap_or_default(); // Publication verrouillée : l'agent attend la validation, pas de relance automatique. let awaiting_approval = calls.iter().any(|call| { - call.get("result").and_then(|r| r.get("code")).and_then(|v| v.as_str()) + call.get("result") + .and_then(|r| r.get("code")) + .and_then(|v| v.as_str()) == Some("publish_approval_required") }); if awaiting_approval { diff --git a/apps/server/src/auto_deploy.rs b/apps/server/src/auto_deploy.rs index 340ed1fc1a..1420b02abd 100644 --- a/apps/server/src/auto_deploy.rs +++ b/apps/server/src/auto_deploy.rs @@ -91,7 +91,6 @@ pub async fn ensure_project_webhook(state: &AppState, project: &Project) -> Opti } } - async fn latest_success_sha(state: &AppState, project_id: i64) -> Option { let row: Option<(Option,)> = sqlx::query_as( "SELECT git_sha FROM deployments WHERE project_id = $1 AND environment = 'production' AND status IN ('success', 'ready') ORDER BY created_at DESC LIMIT 1", @@ -155,7 +154,12 @@ async fn project_is_behind(state: &AppState, project: &Project) -> Option<(u64, } } -pub(crate) async fn deploy_project(state: &AppState, project: &Project, message: &str, target_sha: Option<&str>) { +pub(crate) async fn deploy_project( + state: &AppState, + project: &Project, + message: &str, + target_sha: Option<&str>, +) { let dep_uuid = new_uuid(); let now = now_str(); diff --git a/apps/server/src/auto_deploy_guard.rs b/apps/server/src/auto_deploy_guard.rs index 03e0d7589c..295b60ba90 100644 --- a/apps/server/src/auto_deploy_guard.rs +++ b/apps/server/src/auto_deploy_guard.rs @@ -41,9 +41,14 @@ pub struct Attempt { pub enum Gate { Go, /// Ce SHA a déjà échoué : pas de relance automatique. - SameShaFailed { sha: String }, + SameShaFailed { + sha: String, + }, /// Échecs consécutifs récents : on attend avant de tenter un nouveau commit. - Backoff { until: DateTime, failures: u32 }, + Backoff { + until: DateTime, + failures: u32, + }, } fn usable(sha: &str) -> bool { @@ -74,7 +79,9 @@ pub fn decide(tip: &str, history: &[Attempt], now: DateTime) -> Gate { "success" | "ready" => break, "failed" | "error" => { if a.sha.as_deref().is_some_and(|s| same_sha(s, tip)) { - return Gate::SameShaFailed { sha: tip.chars().take(7).collect() }; + return Gate::SameShaFailed { + sha: tip.chars().take(7).collect(), + }; } failures += 1; last_failure.get_or_insert(a.created_at); @@ -122,9 +129,36 @@ pub async fn gate(state: &AppState, project: &Project, environment: &str, tip: & Gate::Go => {} Gate::SameShaFailed { sha } => { tracing::info!(project = %project.name, environment, sha = %sha, "auto-deploy: SHA déjà en échec, pas de relance"); + crate::wake_gate::record( + &state.pool, + &project.uuid, + "auto_deploy", + &format!("auto_deploy:{environment}"), + "Mise en ligne automatique", + crate::wake_gate::SKIPPED, + &format!("commit {sha} déjà en échec : pas de relance automatique"), + sha, + None, + ) + .await; escalate_once(&state.pool, &project.uuid, environment, sha).await; } Gate::Backoff { until, failures } => { + crate::wake_gate::record( + &state.pool, + &project.uuid, + "auto_deploy", + &format!("auto_deploy:{environment}"), + "Mise en ligne automatique", + crate::wake_gate::SKIPPED, + &format!( + "{failures} échec(s) consécutif(s) : attente jusqu'à {}", + until.format("%H:%M UTC") + ), + tip, + None, + ) + .await; tracing::info!(project = %project.name, environment, failures, until = %until, "auto-deploy: backoff après échecs"); } } @@ -140,7 +174,11 @@ pub async fn set_target_sha(pool: &PgPool, dep_uuid: &str, sha: &str) { } pub fn paused_text(environment: &str, sha: &str) -> String { - let env = if environment == "dev" { "de l'environnement DEV" } else { "en production" }; + let env = if environment == "dev" { + "de l'environnement DEV" + } else { + "en production" + }; format!( "⏸️ Mise en ligne automatique {env} en pause : le commit {sha} a échoué et je ne le relance plus tout seul.\n\ Elle reprendra au prochain commit poussé sur la branche, ou tout de suite avec « Réessayer la mise en ligne ».\n\ @@ -150,7 +188,12 @@ pub fn paused_text(environment: &str, sha: &str) -> String { /// Une seule notification par (projet, environnement, SHA) : la cause P1-07 garde le SHA /// déjà signalé dans `last_dep_uuid`, et compte les SHA bloqués successifs. -pub async fn escalate_once(pool: &PgPool, project_uuid: &str, environment: &str, sha: &str) -> bool { +pub async fn escalate_once( + pool: &PgPool, + project_uuid: &str, + environment: &str, + sha: &str, +) -> bool { let summary = format!("auto-deploy {environment} en pause sur un commit en échec"); let hash = crate::wake_causes::cause_hash(KIND_AUTO_DEPLOY_PAUSED, &summary); let marker = format!("{environment}:{sha}"); @@ -166,9 +209,17 @@ pub async fn escalate_once(pool: &PgPool, project_uuid: &str, environment: &str, if seen.and_then(|(m,)| m).as_deref() == Some(marker.as_str()) { return false; } - let cause = crate::wake_causes::register_failure(pool, project_uuid, KIND_AUTO_DEPLOY_PAUSED, &summary, &marker).await; + let cause = crate::wake_causes::register_failure( + pool, + project_uuid, + KIND_AUTO_DEPLOY_PAUSED, + &summary, + &marker, + ) + .await; let text = paused_text(environment, sha); - crate::deploy_queue::record_event(pool, project_uuid, "deploy", "auto_paused", sha, &text).await; + crate::deploy_queue::record_event(pool, project_uuid, "deploy", "auto_paused", sha, &text) + .await; if let Ok(Some(agent_uuid)) = crate::db::ensure_coordinator_agent(pool, project_uuid).await { let _ = sqlx::query( r#"INSERT INTO agent_messages (uuid, project_uuid, agent_uuid, role, content, tool_calls_json, provider, created_at) @@ -195,16 +246,26 @@ pub async fn backup_draft_before_reset(state: &AppState, project: &Project) { if configured.is_empty() { return; } - let wd = std::path::PathBuf::from(devforge_deploy::resolve_project_workdir(&configured, &project.uuid)); - let branch = project.git_branch.clone().filter(|b| !b.trim().is_empty()).unwrap_or_else(|| "main".into()); - let data = std::path::PathBuf::from(std::env::var("DEVFORGE_DATA_DIR").unwrap_or_else(|_| "/data".into())); + let wd = std::path::PathBuf::from(devforge_deploy::resolve_project_workdir( + &configured, + &project.uuid, + )); + let branch = project + .git_branch + .clone() + .filter(|b| !b.trim().is_empty()) + .unwrap_or_else(|| "main".into()); + let data = std::path::PathBuf::from( + std::env::var("DEVFORGE_DATA_DIR").unwrap_or_else(|_| "/data".into()), + ); let root = crate::draft::backups_root(&data, &project.uuid); let res = tokio::task::spawn_blocking(move || { let st = crate::draft::status(&wd, &branch, None); if !st.available || !st.dirty { return Ok(None); } - crate::draft::backup(&wd, &root, &st.files, "auto-deploy").map(|b| Some((b.id, st.files.len()))) + crate::draft::backup(&wd, &root, &st.files, "auto-deploy") + .map(|b| Some((b.id, st.files.len()))) }) .await; if let Ok(Ok(Some((id, n)))) = res { @@ -228,7 +289,11 @@ mod tests { now - Duration::minutes(min_ago) } fn a(sha: &str, status: &str, min_ago: i64, now: DateTime) -> Attempt { - Attempt { sha: Some(sha.into()), status: status.into(), created_at: at(min_ago, now) } + Attempt { + sha: Some(sha.into()), + status: status.into(), + created_at: at(min_ago, now), + } } const TIP: &str = "0c4711b2e5f0a1b2c3d4e5f60718293a4b5c6d7e"; @@ -236,20 +301,45 @@ mod tests { fn failed_tip_is_never_redeployed_automatically() { let now = Utc::now(); // Scénario devforge-esp : 0c4711b en échec, dernier succès plus ancien. - let h = vec![a("0c4711b", "failed", 1, now), a("89ce0fd", "success", 300, now)]; - assert_eq!(decide(TIP, &h, now), Gate::SameShaFailed { sha: "0c4711b".into() }); + let h = vec![ + a("0c4711b", "failed", 1, now), + a("89ce0fd", "success", 300, now), + ]; + assert_eq!( + decide(TIP, &h, now), + Gate::SameShaFailed { + sha: "0c4711b".into() + } + ); // Même des heures plus tard : toujours bloqué (pas de backoff qui finit par relancer). - let h = vec![a("0c4711b", "failed", 600, now), a("89ce0fd", "success", 900, now)]; - assert_eq!(decide(TIP, &h, now), Gate::SameShaFailed { sha: "0c4711b".into() }); + let h = vec![ + a("0c4711b", "failed", 600, now), + a("89ce0fd", "success", 900, now), + ]; + assert_eq!( + decide(TIP, &h, now), + Gate::SameShaFailed { + sha: "0c4711b".into() + } + ); } #[test] fn new_commit_goes_after_backoff() { let now = Utc::now(); - let h = vec![a("0c4711b", "failed", 1, now), a("89ce0fd", "success", 300, now)]; + let h = vec![ + a("0c4711b", "failed", 1, now), + a("89ce0fd", "success", 300, now), + ]; let new_tip = "abcdef1234567"; - assert!(matches!(decide(new_tip, &h, now), Gate::Backoff { failures: 1, .. })); - let h = vec![a("0c4711b", "failed", 3, now), a("89ce0fd", "success", 300, now)]; + assert!(matches!( + decide(new_tip, &h, now), + Gate::Backoff { failures: 1, .. } + )); + let h = vec![ + a("0c4711b", "failed", 3, now), + a("89ce0fd", "success", 300, now), + ]; assert_eq!(decide(new_tip, &h, now), Gate::Go); } @@ -262,23 +352,38 @@ mod tests { assert_eq!(backoff_for(9), Duration::minutes(60)); assert_eq!(backoff_for(40), Duration::minutes(60)); let now = Utc::now(); - let h: Vec = (0..4).map(|i| a(&format!("aaaaaa{i}"), "failed", 10 + i, now)).collect(); + let h: Vec = (0..4) + .map(|i| a(&format!("aaaaaa{i}"), "failed", 10 + i, now)) + .collect(); // 4 échecs consécutifs → 16 min depuis le dernier (il y a 10 min) : on attend. - assert!(matches!(decide("bbbbbbb", &h, now), Gate::Backoff { failures: 4, .. })); + assert!(matches!( + decide("bbbbbbb", &h, now), + Gate::Backoff { failures: 4, .. } + )); } #[test] fn success_resets_and_unknown_shas_ignored() { let now = Utc::now(); - let h = vec![a("1111111", "success", 1, now), a("0c4711b", "failed", 5, now)]; + let h = vec![ + a("1111111", "success", 1, now), + a("0c4711b", "failed", 5, now), + ]; assert_eq!(decide(TIP, &h, now), Gate::Go); - let h = vec![Attempt { sha: None, status: "failed".into(), created_at: at(30, now) }]; + let h = vec![Attempt { + sha: None, + status: "failed".into(), + created_at: at(30, now), + }]; assert_eq!(decide(TIP, &h, now), Gate::Go); assert!(!same_sha("unknown", "unknown")); assert!(!same_sha("pending", TIP)); assert!(same_sha("0C4711B", TIP)); // Un déploiement en cours ne compte ni comme échec ni comme succès. - let h = vec![a("0c4711b", "running", 1, now), a("2222222", "success", 9, now)]; + let h = vec![ + a("0c4711b", "running", 1, now), + a("2222222", "success", 9, now), + ]; assert_eq!(decide(TIP, &h, now), Gate::Go); } @@ -292,7 +397,11 @@ mod tests { .execute(&pool).await.unwrap(); migrate(&pool).await.unwrap(); let now = Utc::now(); - for (sha, status, min) in [("0c4711b", "failed", 2), ("0c4711b", "failed", 4), ("89ce0fd", "success", 300)] { + for (sha, status, min) in [ + ("0c4711b", "failed", 2), + ("0c4711b", "failed", 4), + ("89ce0fd", "success", 300), + ] { sqlx::query("INSERT INTO deployments (uuid, project_id, status, git_sha, created_at) VALUES ($1, 32, $2, $3, $4)") .bind(new_uuid()).bind(status).bind(sha).bind((now - Duration::minutes(min)).to_rfc3339()) .execute(&pool).await.unwrap(); @@ -304,22 +413,43 @@ mod tests { assert_eq!(h.len(), 3); let mut notified = 0; for _ in 0..12 { - assert_eq!(decide(TIP, &h, Utc::now()), Gate::SameShaFailed { sha: "0c4711b".into() }); + assert_eq!( + decide(TIP, &h, Utc::now()), + Gate::SameShaFailed { + sha: "0c4711b".into() + } + ); if escalate_once(&pool, "esp", "production", "0c4711b").await { notified += 1; } } assert_eq!(notified, 1); assert!(escalate_once(&pool, "esp", "production", "f00ba47").await); - let (n,): (i32,) = sqlx::query_as("SELECT occurrences FROM agent_wake_causes WHERE project_uuid = 'esp' AND kind = $1") - .bind(KIND_AUTO_DEPLOY_PAUSED).fetch_one(&pool).await.unwrap(); + let (n,): (i32,) = sqlx::query_as( + "SELECT occurrences FROM agent_wake_causes WHERE project_uuid = 'esp' AND kind = $1", + ) + .bind(KIND_AUTO_DEPLOY_PAUSED) + .fetch_one(&pool) + .await + .unwrap(); assert_eq!(n, 2); - assert!(matches!(decide("abcdef1", &load_history(&pool, 32, "production").await, Utc::now()), Gate::Backoff { .. })); + assert!(matches!( + decide( + "abcdef1", + &load_history(&pool, 32, "production").await, + Utc::now() + ), + Gate::Backoff { .. } + )); } #[test] fn paused_text_is_clear() { let t = paused_text("production", "0c4711b"); - assert!(t.contains("0c4711b") && t.contains("prochain commit") && t.contains("Réessayer la mise en ligne")); + assert!( + t.contains("0c4711b") + && t.contains("prochain commit") + && t.contains("Réessayer la mise en ligne") + ); } } diff --git a/apps/server/src/db.rs b/apps/server/src/db.rs index 02d6cd807c..fae037e6e5 100644 --- a/apps/server/src/db.rs +++ b/apps/server/src/db.rs @@ -161,11 +161,14 @@ pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::Error> { .execute(pool) .await?; - let _ = sqlx::query("ALTER TABLE project_env_vars ADD COLUMN preview_allowed BIGINT NOT NULL DEFAULT 0") - .execute(pool) - .await; + let _ = sqlx::query( + "ALTER TABLE project_env_vars ADD COLUMN preview_allowed BIGINT NOT NULL DEFAULT 0", + ) + .execute(pool) + .await; crate::wake_causes::migrate(pool).await?; + crate::wake_gate::migrate(pool).await?; crate::auto_deploy_guard::migrate(pool).await?; sqlx::query( diff --git a/apps/server/src/deploy_queue.rs b/apps/server/src/deploy_queue.rs index ab199ff709..17e40b9fbb 100644 --- a/apps/server/src/deploy_queue.rs +++ b/apps/server/src/deploy_queue.rs @@ -117,9 +117,7 @@ pub async fn supersede_in_progress( keep_uuid: &str, ) -> Vec { let now = now_str(); - let note = format!( - "\n[devforge] Déploiement annulé : remplacé par {keep_uuid}\n" - ); + let note = format!("\n[devforge] Déploiement annulé : remplacé par {keep_uuid}\n"); let rows: Vec<(String,)> = sqlx::query_as( r#"UPDATE deployments SET status = 'cancelled', @@ -389,11 +387,7 @@ pub async fn finalize_if_active( /// Déploiements actifs sans tâche vivante depuis plus de `older_than` → `failed`. /// Évite les lignes bloquées en « running » à vie (future droppé, crash de tâche). -pub async fn reap_orphans( - queue: &DeployQueue, - pool: &PgPool, - older_than: chrono::Duration, -) -> u64 { +pub async fn reap_orphans(queue: &DeployQueue, pool: &PgPool, older_than: chrono::Duration) -> u64 { let cutoff = (chrono::Utc::now() - older_than).to_rfc3339(); let rows: Vec<(String, i64)> = sqlx::query_as( r#"SELECT uuid, project_id FROM deployments @@ -927,11 +921,17 @@ mod tests { let queue = DeployQueue::new(); let _rx = queue.register_cancel("dep-run").await; - assert!(cancel_one_deployment(&queue, &pool, "dep-run").await.unwrap()); + assert!(cancel_one_deployment(&queue, &pool, "dep-run") + .await + .unwrap()); assert!(queue.is_cancelled("dep-run").await); // Already terminal → false - assert!(!cancel_one_deployment(&queue, &pool, "dep-ok").await.unwrap()); - assert!(!cancel_one_deployment(&queue, &pool, "dep-run").await.unwrap()); + assert!(!cancel_one_deployment(&queue, &pool, "dep-ok") + .await + .unwrap()); + assert!(!cancel_one_deployment(&queue, &pool, "dep-run") + .await + .unwrap()); let st: (String,) = sqlx::query_as("SELECT status FROM deployments WHERE uuid = 'dep-run'") .fetch_one(&pool) diff --git a/apps/server/src/dev_env.rs b/apps/server/src/dev_env.rs index ae3b8fcf8e..85650b10f0 100644 --- a/apps/server/src/dev_env.rs +++ b/apps/server/src/dev_env.rs @@ -60,10 +60,7 @@ pub fn dev_container_name(project_uuid: &str) -> String { /// /// `apex` = zone publique de l'app (`domain_apex`), si connue. pub fn dev_fqdn(prod_fqdn: &str, apex: Option<&str>) -> Option { - let host = prod_fqdn - .trim() - .trim_end_matches('.') - .to_ascii_lowercase(); + let host = prod_fqdn.trim().trim_end_matches('.').to_ascii_lowercase(); let labels: Vec<&str> = host.split('.').collect(); if labels.len() < 2 || labels.iter().any(|l| l.is_empty()) { return None; @@ -128,13 +125,14 @@ fn normalize_branch(raw: &str) -> Result { && !b.contains("..") && !b.ends_with('/') && !b.ends_with(".lock") - && b - .chars() + && b.chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/')); if valid { Ok(b) } else { - Err(ApiError::message(format!("Nom de branche invalide : {raw}"))) + Err(ApiError::message(format!( + "Nom de branche invalide : {raw}" + ))) } } @@ -340,7 +338,13 @@ async fn deploy_dev_now( ) -> Result, ApiError> { let (_user, _ws, project) = auth_project(&state, &headers, &uuid).await?; dev_supported(&project)?; - if project.dev_branch.as_deref().unwrap_or("").trim().is_empty() { + if project + .dev_branch + .as_deref() + .unwrap_or("") + .trim() + .is_empty() + { return Err(ApiError::message("Environnement dev désactivé.")); } let dep = start_dev_deploy(&state, &project, "Redéploiement dev", None).await?; @@ -509,11 +513,7 @@ pub(crate) async fn run_dev_deploy( }, cancelled: false, }; - let Some(branch) = project - .dev_branch - .clone() - .filter(|b| !b.trim().is_empty()) - else { + let Some(branch) = project.dev_branch.clone().filter(|b| !b.trim().is_empty()) else { return fail("environnement dev désactivé".into()); }; let Some(host) = dev_host_for(project) else { @@ -623,7 +623,10 @@ pub(crate) async fn run_dev_deploy( "superseded", ) .await; - state.deploy.abort_in_flight_swap(&instance, &server_id).await; + state + .deploy + .abort_in_flight_swap(&instance, &server_id) + .await; DeployRunOutcome { result: devforge_deploy::DeployResult { ok: false, @@ -819,8 +822,15 @@ pub(crate) async fn poll_dev(state: &AppState, project: &Project) { let gh = crate::routes::project_github(state, &project.workspace_uuid).await; if let Ok(c) = gh.compare(&owner, &repo, &base, branch).await { if c.ahead_by > 0 { - let tip = c.commits.last().map(|c| c.sha.clone()).filter(|s| !s.is_empty()).unwrap_or_else(|| c.head_sha.clone()); - if crate::auto_deploy_guard::gate(state, project, "dev", &tip).await != crate::auto_deploy_guard::Gate::Go { + let tip = c + .commits + .last() + .map(|c| c.sha.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| c.head_sha.clone()); + if crate::auto_deploy_guard::gate(state, project, "dev", &tip).await + != crate::auto_deploy_guard::Gate::Go + { return; } let msg = format!("DEV · auto-deploy ({} commit(s))", c.ahead_by); @@ -849,7 +859,10 @@ mod tests { #[test] fn dev_host_apex_uses_dev_subdomain() { - assert_eq!(dev_fqdn("example.com", None).as_deref(), Some("dev.example.com")); + assert_eq!( + dev_fqdn("example.com", None).as_deref(), + Some("dev.example.com") + ); assert_eq!( dev_fqdn("www.example.com", None).as_deref(), Some("dev.example.com") @@ -858,7 +871,10 @@ mod tests { dev_fqdn("shop.example.co", Some("shop.example.co")).as_deref(), Some("dev.shop.example.co") ); - assert_eq!(dev_fqdn("EXAMPLE.com.", None).as_deref(), Some("dev.example.com")); + assert_eq!( + dev_fqdn("EXAMPLE.com.", None).as_deref(), + Some("dev.example.com") + ); } #[test] @@ -899,7 +915,10 @@ mod tests { #[test] fn branch_names_are_validated() { - assert_eq!(normalize_branch("refs/heads/feat/x").ok().as_deref(), Some("feat/x")); + assert_eq!( + normalize_branch("refs/heads/feat/x").ok().as_deref(), + Some("feat/x") + ); assert!(normalize_branch("main; rm -rf /").is_err()); assert!(normalize_branch("-x").is_err()); assert!(normalize_branch("a..b").is_err()); diff --git a/apps/server/src/dns.rs b/apps/server/src/dns.rs index 6dcf78e966..ed5251985b 100644 --- a/apps/server/src/dns.rs +++ b/apps/server/src/dns.rs @@ -406,7 +406,11 @@ pub(crate) async fn lookup_target(state: &AppState, fqdn: &str) -> Result