From 7a93286a5317296cd55ed83f6bee14c0f1239c27 Mon Sep 17 00:00:00 2001 From: Mathieu JESER Date: Tue, 13 Oct 2026 23:15:05 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(corrections):=20tuile=20Corrections=20?= =?UTF-8?q?(=C3=A9tapes,=20rapport,=20Appliquer),=20r=C3=A9paration=20auto?= =?UTF-8?q?=20test=C3=A9e=20par=20Rustine,=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/app/jeser/devforge/MainActivity.kt | 6 +- .../java/app/jeser/devforge/data/ApiClient.kt | 14 + .../java/app/jeser/devforge/data/Models.kt | 44 + .../app/jeser/devforge/notify/NotifPrefs.kt | 1 + .../app/jeser/devforge/notify/Notifier.kt | 18 +- .../java/app/jeser/devforge/ui/AppRoot.kt | 13 +- .../devforge/ui/project/CorrectionsSheet.kt | 201 +++ .../jeser/devforge/ui/project/DraftSheets.kt | 4 +- .../devforge/ui/project/ProjectScreen.kt | 37 +- .../devforge/ui/project/ProjectViewModel.kt | 45 +- apps/server/src/auto_deploy.rs | 20 +- apps/server/src/correction.rs | 1369 +++++++++++++++++ apps/server/src/db.rs | 1 + apps/server/src/draft.rs | 149 +- apps/server/src/draft_origin.rs | 14 +- apps/server/src/main.rs | 9 + apps/server/src/mobile_routes.rs | 3 + apps/server/src/routes.rs | 95 +- apps/server/src/wake_gate.rs | 19 +- apps/web/src/components/CorrectionsPanel.tsx | 304 ++++ apps/web/src/components/DraftPanel.tsx | 4 +- apps/web/src/components/ProjectHome.tsx | 59 +- apps/web/src/components/ProjectWorkspace.tsx | 39 +- apps/web/src/lib/api.ts | 39 + apps/web/src/lib/personas.ts | 1 + crates/agent/src/lib.rs | 1 + crates/agent/src/roles.rs | 21 + crates/agent/src/tools/preview.rs | 9 + crates/agent/src/tools/workdir_cmd.rs | 67 + crates/deploy/src/lib.rs | 8 +- package-lock.json | 2 +- 31 files changed, 2481 insertions(+), 135 deletions(-) create mode 100644 apps/android/app/src/main/java/app/jeser/devforge/ui/project/CorrectionsSheet.kt create mode 100644 apps/server/src/correction.rs create mode 100644 apps/web/src/components/CorrectionsPanel.tsx diff --git a/apps/android/app/src/main/java/app/jeser/devforge/MainActivity.kt b/apps/android/app/src/main/java/app/jeser/devforge/MainActivity.kt index 0fb661ef50..016e02b0fe 100644 --- a/apps/android/app/src/main/java/app/jeser/devforge/MainActivity.kt +++ b/apps/android/app/src/main/java/app/jeser/devforge/MainActivity.kt @@ -40,6 +40,7 @@ class MainActivity : ComponentActivity() { /** Projet à ouvrir (notification touchée). */ private var deepLinkProject by mutableStateOf(null) + private var deepLinkCorrections by mutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge( @@ -94,6 +95,8 @@ class MainActivity : ComponentActivity() { selected = selected, onSelect = { selected = it }, onSignOut = { graph.signOut() }, + openCorrectionsFor = deepLinkCorrections, + onCorrectionsOpened = { deepLinkCorrections = null }, ) } } @@ -129,8 +132,9 @@ class MainActivity : ComponentActivity() { return } intent.getStringExtra(Notifier.EXTRA_PROJECT)?.let { - deepLinkProject = it + if (intent.getStringExtra(Notifier.EXTRA_OPEN) == Notifier.OPEN_CORRECTIONS) deepLinkCorrections = it else deepLinkProject = it intent.removeExtra(Notifier.EXTRA_PROJECT) + intent.removeExtra(Notifier.EXTRA_OPEN) } } diff --git a/apps/android/app/src/main/java/app/jeser/devforge/data/ApiClient.kt b/apps/android/app/src/main/java/app/jeser/devforge/data/ApiClient.kt index 2c23d0cf06..17657ed10b 100644 --- a/apps/android/app/src/main/java/app/jeser/devforge/data/ApiClient.kt +++ b/apps/android/app/src/main/java/app/jeser/devforge/data/ApiClient.kt @@ -319,6 +319,20 @@ class ApiClient( suspend fun drafts(): List = get("/drafts", DraftList.serializer()).drafts + /* ---- Corrections ---- */ + suspend fun corrections(uuid: String): CorrectionsState = + get("/projects/${enc(uuid)}/corrections", CorrectionsState.serializer()) + + /** « Appliquer » : rattrape GitHub, envoie et met en ligne. Seulement après confirmation. */ + suspend fun correctionApply(uuid: String): CorrectionApplied = + send("POST", "/projects/${enc(uuid)}/corrections/apply", buildJsonObject { put("confirm", true) }, CorrectionApplied.serializer()) + + suspend fun correctionReject(uuid: String): OkOnly = + send("POST", "/projects/${enc(uuid)}/corrections/reject", buildJsonObject { put("confirm", true) }, OkOnly.serializer()) + + suspend fun correctionRedo(uuid: String): OkOnly = + send("POST", "/projects/${enc(uuid)}/corrections/redo", buildJsonObject { put("confirm", true) }, OkOnly.serializer()) + /* Mémoire des agents (écran natif Réglages → Mémoire). */ suspend fun memoryList(tier: String): MemoryList = get("/memory?tier=${enc(tier)}", MemoryList.serializer()) diff --git a/apps/android/app/src/main/java/app/jeser/devforge/data/Models.kt b/apps/android/app/src/main/java/app/jeser/devforge/data/Models.kt index 59d3ff24cd..989810d62d 100644 --- a/apps/android/app/src/main/java/app/jeser/devforge/data/Models.kt +++ b/apps/android/app/src/main/java/app/jeser/devforge/data/Models.kt @@ -422,6 +422,50 @@ data class DraftDiscarded(@SerialName("backup_id") val backupId: String = "", va @Serializable data class DraftRestored(val files: Int = 0) +/* ---- Corrections (vue simple, sans jargon) ---- */ +@Serializable +data class CorrectionStep(val key: String = "", val label: String = "", val state: String = "todo") + +@Serializable +data class CorrectionReport( + val broke: String = "", + val changed: String = "", + val files: List = emptyList(), + @SerialName("test_ok") val testOk: Boolean? = null, + val test: String = "", + val todo: String = "", +) + +@Serializable +data class Correction( + val id: String = "", + val stage: String = "", + @SerialName("status_label") val statusLabel: String = "", + val persona: String = "", + val summary: String = "", + val files: List = emptyList(), + val message: String? = null, + val note: String? = null, + val steps: List = emptyList(), + val report: CorrectionReport = CorrectionReport(), + @SerialName("backup_id") val backupId: String? = null, + val restorable: Boolean = false, +) + +@Serializable +data class CorrectionsState(val current: Correction? = null, val history: List = emptyList()) + +@Serializable +data class CorrectionApplied( + val ok: Boolean = false, + val deployed: Boolean = false, + val conflict: Boolean = false, + val message: String? = null, +) + +@Serializable +data class OkOnly(val ok: Boolean = false) + @Serializable data class DraftReverted(@SerialName("backup_id") val backupId: String = "", val path: String = "") diff --git a/apps/android/app/src/main/java/app/jeser/devforge/notify/NotifPrefs.kt b/apps/android/app/src/main/java/app/jeser/devforge/notify/NotifPrefs.kt index 44dd5f2ca5..4dbe24e6b4 100644 --- a/apps/android/app/src/main/java/app/jeser/devforge/notify/NotifPrefs.kt +++ b/apps/android/app/src/main/java/app/jeser/devforge/notify/NotifPrefs.kt @@ -7,6 +7,7 @@ enum class NotifKind(val key: String, val title: String, val description: String DeployFailed("deploy_failed", "Mise en ligne échouée", "Rustine 🩹 te prévient et peut réparer."), AppDown("app_down", "App qui ne répond plus", "Phare 🗼 a repéré une app en panne."), SpecWaiting("spec_waiting", "Braise attend ton OK", "Une spec est prête à relire."), + Corrections("corrections", "Corrections", "Correction prête, en ligne ou en échec."), } interface NotifSettings { diff --git a/apps/android/app/src/main/java/app/jeser/devforge/notify/Notifier.kt b/apps/android/app/src/main/java/app/jeser/devforge/notify/Notifier.kt index 343cb6e1b0..888c0366c4 100644 --- a/apps/android/app/src/main/java/app/jeser/devforge/notify/Notifier.kt +++ b/apps/android/app/src/main/java/app/jeser/devforge/notify/Notifier.kt @@ -19,6 +19,11 @@ object Notifier { const val CHANNEL_ALERTS = "alerts" const val CHANNEL_WAITING = "waiting" const val EXTRA_PROJECT = "project_uuid" + const val EXTRA_OPEN = "open" + const val OPEN_CORRECTIONS = "corrections" + + /** Réglage qui couvre un type d'événement (les 3 étapes de correction partagent un réglage). */ + fun settingKey(kind: String): String = if (kind.startsWith("correction_") || kind == "fix_ready") NotifKind.Corrections.key else kind fun createChannels(context: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return @@ -40,15 +45,16 @@ object Notifier { return NotificationManagerCompat.from(context).areNotificationsEnabled() } - fun openProjectIntent(context: Context, projectUuid: String): PendingIntent { + fun openProjectIntent(context: Context, projectUuid: String, open: String? = null): PendingIntent { val intent = Intent(context, MainActivity::class.java).apply { - action = "app.jeser.devforge.OPEN_PROJECT.$projectUuid" + action = "app.jeser.devforge.OPEN_PROJECT.$projectUuid${open?.let { ".$it" } ?: ""}" putExtra(EXTRA_PROJECT, projectUuid) + open?.let { putExtra(EXTRA_OPEN, it) } flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } return PendingIntent.getActivity( context, - projectUuid.hashCode(), + (projectUuid + (open ?: "")).hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) @@ -56,18 +62,18 @@ object Notifier { fun show(context: Context, events: List) { val settings = PrefsNotifSettings(context) - val wanted = events.filter { settings.enabled(it.kind) } + val wanted = events.filter { settings.enabled(settingKey(it.kind)) } if (wanted.isEmpty() || !canNotify(context)) return val nm = NotificationManagerCompat.from(context) for (e in wanted.take(6)) { - val channel = if (e.kind == "spec_waiting") CHANNEL_WAITING else CHANNEL_ALERTS + val channel = if (e.kind == "spec_waiting" || e.kind == "fix_ready" || e.kind == "correction_live") CHANNEL_WAITING else CHANNEL_ALERTS val n = NotificationCompat.Builder(context, channel) .setSmallIcon(R.drawable.ic_stat_devforge) .setColor(0xFFA78BFA.toInt()) .setContentTitle(e.title) .setContentText(e.body) .setStyle(NotificationCompat.BigTextStyle().bigText(e.body)) - .setContentIntent(openProjectIntent(context, e.projectUuid)) + .setContentIntent(openProjectIntent(context, e.projectUuid, if (e.kind.startsWith("correction_") || e.kind == "fix_ready") OPEN_CORRECTIONS else null)) .setAutoCancel(true) .setCategory(if (channel == CHANNEL_ALERTS) NotificationCompat.CATEGORY_ERROR else NotificationCompat.CATEGORY_REMINDER) .setGroup("devforge") diff --git a/apps/android/app/src/main/java/app/jeser/devforge/ui/AppRoot.kt b/apps/android/app/src/main/java/app/jeser/devforge/ui/AppRoot.kt index 4faf02846f..a53f471864 100644 --- a/apps/android/app/src/main/java/app/jeser/devforge/ui/AppRoot.kt +++ b/apps/android/app/src/main/java/app/jeser/devforge/ui/AppRoot.kt @@ -64,6 +64,8 @@ fun HomeRoot( selected: String?, onSelect: (String?) -> Unit, onSignOut: () -> Unit, + openCorrectionsFor: String? = null, + onCorrectionsOpened: () -> Unit = {}, ) { val context = LocalContext.current val uri = LocalUriHandler.current @@ -88,6 +90,10 @@ fun HomeRoot( } } var draftFor by rememberSaveable { mutableStateOf(null) } + // Notification « correction » : ouvre directement la feuille Corrections de l'app. + LaunchedEffect(openCorrectionsFor) { + openCorrectionsFor?.let { draftFor = it; onSelect(it); onCorrectionsOpened() } + } var memoryOpen by rememberSaveable { mutableStateOf(false) } // Écran plein (app, mémoire) : la barre Apps / Braise / Réglages reste visible dessous. val screen: (@Composable (Modifier) -> Unit)? = when { @@ -320,6 +326,11 @@ private fun ProjectRoute(graph: AppGraph, uuid: String, onBack: (() -> Unit)?, o onOpenPreview = vm::openPreview, onUrlOpened = vm::urlOpened, onOpenDraft = vm::openDraft, + onOpenCorrections = vm::openCorrections, + onApplyCorrection = vm::applyCorrection, + onRejectCorrection = vm::rejectCorrection, + onRedoCorrection = vm::redoCorrection, + onRestoreCorrection = vm::restoreCorrection, onDraftValidate = vm::validateDraft, onDraftDiscard = vm::discardDraft, onDraftRevert = vm::revertDraftFile, @@ -328,7 +339,7 @@ private fun ProjectRoute(graph: AppGraph, uuid: String, onBack: (() -> Unit)?, o onDraftValidatedSeen = vm::draftValidatedSeen, onUndoShown = vm::undoShown, ), - initialSheet = if (openDraft) ProjectSheet.Draft else ProjectSheet.None, + initialSheet = if (openDraft) ProjectSheet.Corrections else ProjectSheet.None, ) } diff --git a/apps/android/app/src/main/java/app/jeser/devforge/ui/project/CorrectionsSheet.kt b/apps/android/app/src/main/java/app/jeser/devforge/ui/project/CorrectionsSheet.kt new file mode 100644 index 0000000000..db613a8183 --- /dev/null +++ b/apps/android/app/src/main/java/app/jeser/devforge/ui/project/CorrectionsSheet.kt @@ -0,0 +1,201 @@ +package app.jeser.devforge.ui.project + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.History +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.jeser.devforge.data.Correction +import app.jeser.devforge.data.CorrectionStep +import app.jeser.devforge.data.CorrectionsState +import app.jeser.devforge.ui.theme.DfColors + +/** Couleur d'une étape de correction. */ +fun correctionTone(stage: String): Color = when (stage) { + "proposed" -> DfColors.Warn + "live" -> DfColors.Ok + "failed", "conflict" -> DfColors.Danger + "deploying" -> DfColors.Accent + else -> DfColors.InkMuted +} + +/** Libellé court pour la tuile. */ +fun correctionsTileText(c: CorrectionsState?): String { + val cur = c?.current + if (cur != null) return "${cur.statusLabel} · ${cur.persona}" + val last = c?.history?.firstOrNull() ?: return "Rien en attente" + return "Dernière : ${last.statusLabel.lowercase()}" +} + +@Composable +fun CorrectionStepper(steps: List) { + Row(Modifier.fillMaxWidth().testTag("correction-steps"), verticalAlignment = Alignment.Top) { + steps.forEach { s -> + val c = when (s.state) { + "done" -> DfColors.Ok + "failed" -> DfColors.Danger + "active" -> DfColors.Accent + else -> DfColors.InkFaint + } + Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) { + Box(Modifier.size(28.dp).background(c.copy(alpha = .15f), CircleShape), contentAlignment = Alignment.Center) { + when (s.state) { + "done" -> Icon(Icons.Filled.Check, null, tint = c, modifier = Modifier.size(16.dp)) + "failed" -> Icon(Icons.Filled.Close, null, tint = c, modifier = Modifier.size(16.dp)) + "active" -> CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp, color = c) + else -> Box(Modifier.size(8.dp).background(c, CircleShape)) + } + } + Text(s.label, fontSize = 11.sp, color = DfColors.InkMuted, textAlign = TextAlign.Center, maxLines = 2, + overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp, start = 2.dp, end = 2.dp)) + } + } + } +} + +@Composable +private fun Section(title: String, body: @Composable ColumnScope.() -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title.uppercase(), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = DfColors.InkFaint) + body() + } +} + +/** Feuille « Corrections » : rapport de l'agent, étapes, fichiers (diff lisible), Appliquer / Rejeter. */ +@Composable +fun CorrectionsSheet( + state: ProjectUiState, + onDismiss: () -> Unit, + onOpenFile: (String) -> Unit, + onApply: (() -> Unit) -> Unit, + onReject: (() -> Unit) -> Unit, + onRedo: (() -> Unit) -> Unit, + onRestore: (String) -> Unit, +) { + val data = state.corrections + val c: Correction? = data?.current + var confirm by rememberSaveable { mutableStateOf(null) } + var details by rememberSaveable { mutableStateOf(false) } + val busy = state.draftBusy != null + val conflict = state.correctionConflict ?: c?.takeIf { it.stage == "conflict" }?.note?.ifBlank { null } + DraftSheetFrame(onDismiss, busy = busy) { + Text("Corrections", style = MaterialTheme.typography.titleLarge, modifier = Modifier.testTag("corrections-sheet")) + ErrorBox(state.draftError) + if (c == null) { + Text("Aucune correction en attente. Quand une mise en ligne échoue, Rustine prépare et teste une correction, puis te prévient.", + color = DfColors.InkMuted) + } else { + Text(buildString { append("Correction proposée par "); append(c.persona) }, color = DfColors.InkMuted, fontSize = 14.sp) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(c.summary, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, modifier = Modifier.weight(1f)) + Text(c.statusLabel, color = correctionTone(c.stage), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 8.dp)) + } + CorrectionStepper(c.steps) + conflict?.let { ErrorBox(it) } + val r = c.report + if (r.broke.isNotBlank()) Section("Ce qui a cassé") { Text(r.broke, fontSize = 14.sp) } + Section("Ce que j'ai changé") { + Text(r.changed, fontSize = 14.sp) + r.files.forEach { f -> + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable { onOpenFile(f) }.testTag("correction-file"), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Filled.Description, null, tint = DfColors.InkFaint, modifier = Modifier.size(18.dp)) + Text(f, fontFamily = FontFamily.Monospace, fontSize = 12.5.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(horizontal = 8.dp)) + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Voir les changements", tint = DfColors.InkFaint) + } + } + } + Section("Test") { + Text(r.test, fontSize = 14.sp, color = when (r.testOk) { true -> DfColors.Ok; false -> DfColors.Danger; null -> DfColors.InkMuted }) + } + Surface(color = Color.White.copy(alpha = .04f), shape = RoundedCornerShape(12.dp)) { + Column(Modifier.fillMaxWidth().padding(10.dp)) { + Section("Ce que tu dois faire") { Text(r.todo, fontWeight = FontWeight.Medium, fontSize = 14.sp) } + } + } + c.message?.takeIf { it.isNotBlank() }?.let { msg -> + TextButton(onClick = { details = !details }, modifier = Modifier.heightIn(min = 48.dp)) { + Text(if (details) "Masquer les détails" else "Détails", fontSize = 13.sp, color = DfColors.InkFaint) + } + if (details) Text(msg, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = DfColors.InkMuted) + } + val canAct = c.stage == "proposed" || c.stage == "conflict" || c.stage == "testing" + if (canAct) { + if (confirm != null) { + Text( + when (confirm) { + "apply" -> "Appliquer cette correction et mettre l'app en ligne ?" + "reject" -> "Rejeter cette correction ? Elle reste récupérable 7 jours." + else -> "Laisser Rustine refaire la correction sur le code à jour ?" + }, + fontSize = 14.sp, + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedButton(onClick = { confirm = null }, enabled = !busy, modifier = Modifier.weight(1f).heightIn(min = 48.dp)) { Text("Annuler") } + Button( + onClick = { + val done = { confirm = null; onDismiss() } + when (confirm) { + "apply" -> onApply(done) + "reject" -> onReject(done) + else -> onRedo(done) + } + }, + enabled = !busy, + modifier = Modifier.weight(1f).heightIn(min = 48.dp).testTag("correction-confirm"), + ) { if (busy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) else Text("Confirmer") } + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedButton(onClick = { confirm = "reject" }, modifier = Modifier.weight(1f).heightIn(min = 48.dp).testTag("correction-reject")) { Text("Rejeter") } + if (conflict != null) { + Button(onClick = { confirm = "redo" }, modifier = Modifier.weight(1f).heightIn(min = 48.dp)) { Text("Laisser Rustine refaire", maxLines = 1, overflow = TextOverflow.Ellipsis) } + } else { + Button(onClick = { confirm = "apply" }, enabled = c.stage != "testing", + modifier = Modifier.weight(1f).heightIn(min = 48.dp).testTag("correction-apply")) { Text("Appliquer") } + } + } + } + } + } + val history = data?.history.orEmpty() + if (history.isNotEmpty()) { + Text("HISTORIQUE", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = DfColors.InkFaint, modifier = Modifier.padding(top = 4.dp)) + history.forEach { h -> + Row(Modifier.fillMaxWidth().heightIn(min = 44.dp), verticalAlignment = Alignment.CenterVertically) { + Text(h.statusLabel, color = correctionTone(h.stage), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(84.dp)) + Text(h.summary, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + if (h.restorable && c == null && h.backupId != null) { + IconButton(onClick = { onRestore(h.backupId) }, modifier = Modifier.size(48.dp)) { + Icon(Icons.Filled.History, contentDescription = "Remettre cette correction") + } + } + } + } + } + } +} diff --git a/apps/android/app/src/main/java/app/jeser/devforge/ui/project/DraftSheets.kt b/apps/android/app/src/main/java/app/jeser/devforge/ui/project/DraftSheets.kt index 91c91cb125..16195d3f3a 100644 --- a/apps/android/app/src/main/java/app/jeser/devforge/ui/project/DraftSheets.kt +++ b/apps/android/app/src/main/java/app/jeser/devforge/ui/project/DraftSheets.kt @@ -242,7 +242,7 @@ fun WorkspaceSheet( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun DraftSheetFrame(onDismiss: () -> Unit, busy: Boolean = false, modal: Boolean = false, content: @Composable ColumnScope.() -> Unit) { +internal fun DraftSheetFrame(onDismiss: () -> Unit, busy: Boolean = false, modal: Boolean = false, content: @Composable ColumnScope.() -> Unit) { val body: @Composable ColumnScope.() -> Unit = { Column( Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp).padding(bottom = 24.dp), @@ -260,7 +260,7 @@ private fun DraftSheetFrame(onDismiss: () -> Unit, busy: Boolean = false, modal: } @Composable -private fun ErrorBox(text: String?) { +internal fun ErrorBox(text: String?) { text ?: return Surface(color = DfColors.Danger.copy(alpha = .1f), shape = RoundedCornerShape(12.dp), border = BorderStroke(1.dp, DfColors.Danger.copy(alpha = .3f))) { Text(text, color = DfColors.Danger, fontSize = 14.sp, modifier = Modifier.fillMaxWidth().padding(12.dp)) diff --git a/apps/android/app/src/main/java/app/jeser/devforge/ui/project/ProjectScreen.kt b/apps/android/app/src/main/java/app/jeser/devforge/ui/project/ProjectScreen.kt index 4efa6665ed..6a3207f173 100644 --- a/apps/android/app/src/main/java/app/jeser/devforge/ui/project/ProjectScreen.kt +++ b/apps/android/app/src/main/java/app/jeser/devforge/ui/project/ProjectScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.Subject import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.LocalFireDepartment @@ -104,6 +105,11 @@ data class ProjectActions( val onOpenPreview: () -> Unit = {}, val onUrlOpened: () -> Unit = {}, val onOpenDraft: () -> Unit = {}, + val onOpenCorrections: () -> Unit = {}, + val onApplyCorrection: (() -> Unit) -> Unit = {}, + val onRejectCorrection: (() -> Unit) -> Unit = {}, + val onRedoCorrection: (() -> Unit) -> Unit = {}, + val onRestoreCorrection: (String) -> Unit = {}, val onDraftValidate: (String) -> Unit = {}, val onDraftDiscard: (() -> Unit) -> Unit = {}, val onDraftRevert: (String, () -> Unit) -> Unit = { _, _ -> }, @@ -121,7 +127,7 @@ data class ProjectActions( ) /** Feuilles de la page app (une seule ouverte à la fois). */ -enum class ProjectSheet { None, Details, Deploys, Stop, Restart, Deploy, NewFeature, Workspace, Draft, DraftFile, DraftValidate, DraftDiscard, Chat } +enum class ProjectSheet { None, Details, Deploys, Stop, Restart, Deploy, NewFeature, Workspace, Draft, DraftFile, DraftValidate, DraftDiscard, Chat, Corrections } private data class Chip(val key: String, val label: String, val danger: Boolean = false) @@ -136,7 +142,10 @@ fun ProjectScreen( ) { var sheet by rememberSaveable { mutableStateOf(initialSheet) } var draftPath by rememberSaveable { mutableStateOf(initialDraftPath) } - LaunchedEffect(Unit) { if (initialSheet == ProjectSheet.Draft) actions.onOpenDraft() } + LaunchedEffect(Unit) { + if (initialSheet == ProjectSheet.Draft) actions.onOpenDraft() + if (initialSheet == ProjectSheet.Corrections) actions.onOpenCorrections() + } var input by rememberSaveable { mutableStateOf("") } val snackbar = remember { SnackbarHostState() } val uri = LocalUriHandler.current @@ -161,8 +170,10 @@ fun ProjectScreen( val project = state.project // Fermer le brouillon ramène au Workspace s'il a été ouvert depuis là. var draftFromWorkspace by rememberSaveable { mutableStateOf(false) } + var fileFromCorrections by rememberSaveable { mutableStateOf(false) } val openDraft = { actions.onOpenDraft(); sheet = ProjectSheet.Draft } // Tuile Workspace : brouillon en attente → directement le brouillon ; sinon les tuiles du Workspace. + val openCorrections = { actions.onOpenCorrections(); sheet = ProjectSheet.Corrections } val openWorkspace = { actions.onOpenDraft() if (state.draft?.dirty == true) { @@ -235,7 +246,7 @@ fun ProjectScreen( cols = cols, showBraise = showBraise, onBraise = { sheet = ProjectSheet.Chat }, - onWorkspace = openWorkspace, + onWorkspace = openCorrections, onDeploys = { sheet = ProjectSheet.Deploys }, onSettings = { sheet = ProjectSheet.Details }, ) @@ -311,17 +322,26 @@ fun ProjectScreen( state, onDismiss = { if (draftFromWorkspace) { draftFromWorkspace = false; sheet = ProjectSheet.Workspace } else close() }, onOpenDiscussion = { sheet = ProjectSheet.Chat }, - onOpenFile = { p -> draftPath = p; sheet = ProjectSheet.DraftFile }, + onOpenFile = { p -> draftPath = p; fileFromCorrections = false; sheet = ProjectSheet.DraftFile }, onValidate = { actions.onDraftValidatedSeen(); sheet = ProjectSheet.DraftValidate }, onDiscard = { sheet = ProjectSheet.DraftDiscard }, onUpdate = actions.onDraftUpdate, onRestore = actions.onDraftRestore, onOpenPreview = { actions.onOpenPreview(); close() }, ) + ProjectSheet.Corrections -> CorrectionsSheet( + state, + onDismiss = close, + onOpenFile = { p -> draftPath = p; fileFromCorrections = true; sheet = ProjectSheet.DraftFile }, + onApply = actions.onApplyCorrection, + onReject = actions.onRejectCorrection, + onRedo = actions.onRedoCorrection, + onRestore = actions.onRestoreCorrection, + ) ProjectSheet.DraftFile -> DraftFileSheet( state, path = draftPath.orEmpty(), - onBack = { sheet = ProjectSheet.Draft }, + onBack = { sheet = if (fileFromCorrections) ProjectSheet.Corrections else ProjectSheet.Draft }, onDismiss = close, onRevert = { p -> actions.onDraftRevert(p) { sheet = ProjectSheet.Draft } }, ) @@ -570,9 +590,10 @@ fun ProjectTiles( add(TileSpec("braise", "Braise", if (waiting) "Attend ton OK" else "Discuter, faire évoluer", Icons.Filled.LocalFireDepartment, if (waiting) DfColors.Warn else DfColors.Accent, waiting, onBraise)) } - val dirty = draft?.dirty == true - add(TileSpec("workspace", "Workspace", if (dirty) "Brouillon · ${filesLabel(draft!!.count)}" else "Brouillon et aperçu", Icons.Filled.Description, - if (dirty) DfColors.Warn else DfColors.InkMuted, dirty, onWorkspace)) + val cur = state.corrections?.current + val alert = cur != null && cur.stage in setOf("proposed", "conflict", "failed") + add(TileSpec("corrections", "Corrections", correctionsTileText(state.corrections), Icons.Filled.Build, + cur?.let { correctionTone(it.stage) } ?: DfColors.InkMuted, alert, onWorkspace)) val (dl, dc) = latest?.let { deployLabel(it) } ?: ("Aucune" to DfColors.InkFaint) val when_ = latest?.let { relativeTime(it.createdAt) }?.takeIf { it.isNotEmpty() } add(TileSpec("deploys", "Déploiements", listOfNotNull(dl, when_).joinToString(" · "), Icons.Filled.History, diff --git a/apps/android/app/src/main/java/app/jeser/devforge/ui/project/ProjectViewModel.kt b/apps/android/app/src/main/java/app/jeser/devforge/ui/project/ProjectViewModel.kt index aeb4cff99e..d5ec15cd8c 100644 --- a/apps/android/app/src/main/java/app/jeser/devforge/ui/project/ProjectViewModel.kt +++ b/apps/android/app/src/main/java/app/jeser/devforge/ui/project/ProjectViewModel.kt @@ -90,6 +90,8 @@ data class ProjectUiState( val openUrl: String? = null, /** Brouillon : changements locaux pas encore sur GitHub. */ val draft: DraftStatus? = null, + val corrections: app.jeser.devforge.data.CorrectionsState? = null, + val correctionConflict: String? = null, val draftDiff: List? = null, val draftDiffError: String? = null, /** Action brouillon en cours : validate, discard, revert, update, restore. */ @@ -158,6 +160,7 @@ class ProjectViewModel(private val graph: AppGraph, val uuid: String) : ViewMode launch { runCatching { api.git(uuid) }.onSuccess { g -> _state.update { it.copy(git = g) } } } launch { runCatching { api.preview(uuid) }.onSuccess { pv -> _state.update { it.copy(preview = pv) } } } launch { refreshDraft(fetch = true) } + launch { refreshCorrections() } val agents = api.agents(uuid) val coord = agents.firstOrNull { it.role == "coordinator" } _state.update { it.copy(coordinatorUuid = coord?.uuid, agents = agents) } @@ -229,7 +232,7 @@ class ProjectViewModel(private val graph: AppGraph, val uuid: String) : ViewMode _state.update { s -> if (s.project == p) s else s.copy(project = p) } } } - if (tick % 2 == 0) refreshDraft() + if (tick % 2 == 0) { refreshDraft(); refreshCorrections() } if (tick % 6 == 0) { runCatching { api.git(uuid) }.onSuccess { g -> _state.update { s -> if (s.git == g) s else s.copy(git = g) } } runCatching { api.agents(uuid) }.onSuccess { a -> _state.update { s -> if (s.agents == a) s else s.copy(agents = a) } } @@ -647,5 +650,45 @@ class ProjectViewModel(private val graph: AppGraph, val uuid: String) : ViewMode } fun undoShown() = _state.update { it.copy(undo = null) } + private suspend fun refreshCorrections() { + runCatching { api.corrections(uuid) }.onSuccess { c -> _state.update { s -> if (s.corrections == c) s else s.copy(corrections = c) } } + } + + fun openCorrections() { + _state.update { it.copy(correctionConflict = null) } + openDraft() + viewModelScope.launch { refreshCorrections() } + } + + fun applyCorrection(onDone: () -> Unit) = draftAction("apply") { + val r = api.correctionApply(uuid) + if (r.conflict) { + _state.update { it.copy(correctionConflict = r.message ?: "Le code a changé au même endroit.") } + } else { + _state.update { it.copy(notice = if (r.deployed) "Correction appliquée · mise en ligne en cours" else "Correction envoyée · pas de nouvelle tentative automatique") } + onDone() + } + refreshDraft(); refreshCorrections() + } + + fun rejectCorrection(onDone: () -> Unit) = draftAction("reject") { + api.correctionReject(uuid) + _state.update { it.copy(notice = "Correction rejetée · récupérable 7 jours") } + refreshDraft(); refreshCorrections() + onDone() + } + + fun redoCorrection(onDone: () -> Unit) = draftAction("redo") { + api.correctionRedo(uuid) + _state.update { it.copy(correctionConflict = null, notice = "Rustine refait la correction") } + refreshDraft(); refreshCorrections() + onDone() + } + + fun restoreCorrection(backupId: String) = draftAction("restore") { + api.draftRestore(uuid, backupId) + refreshDraft(); refreshCorrections() + } + fun draftValidatedSeen() = _state.update { it.copy(draftValidated = null) } } diff --git a/apps/server/src/auto_deploy.rs b/apps/server/src/auto_deploy.rs index 1420b02abd..1f6829f91b 100644 --- a/apps/server/src/auto_deploy.rs +++ b/apps/server/src/auto_deploy.rs @@ -134,12 +134,17 @@ async fn project_is_behind(state: &AppState, project: &Project) -> Option<(u64, .to_string() }) .unwrap_or_else(|| "Auto-deploy (poll)".into()); - let tip_sha = c - .commits - .last() - .map(|c| c.sha.clone()) + // La comparaison GitHub tronque la liste des commits (250 max) : la pointe réelle + // de la branche est head_sha ; le dernier commit listé n'est qu'un repli. + let tip_sha = Some(c.head_sha.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| c.head_sha.clone()); + .or_else(|| { + c.commits + .last() + .map(|c| c.sha.clone()) + .filter(|s| !s.is_empty()) + }) + .unwrap_or_default(); Some((c.ahead_by, tip, tip_sha)) } Ok(_) => None, @@ -160,6 +165,11 @@ pub(crate) async fn deploy_project( message: &str, 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é"); + return; + } let dep_uuid = new_uuid(); let now = now_str(); diff --git a/apps/server/src/correction.rs b/apps/server/src/correction.rs new file mode 100644 index 0000000000..a2910ba13e --- /dev/null +++ b/apps/server/src/correction.rs @@ -0,0 +1,1369 @@ +//! « Corrections » : vue simple des changements proposés par les agents (ex-brouillon). +//! +//! Étapes : Problème détecté → Correction proposée → Testée → En ligne (+ échec / abandon). +//! Aucune notion Git côté interface : « Appliquer » rattrape GitHub tout seul (rebase), envoie, +//! puis lance la mise en ligne. Un SHA déjà en échec n'est jamais relancé automatiquement. +//! Les corrections dépassées sont archivées automatiquement (sauvegarde 7 jours restaurable). + +use std::time::Duration; + +use axum::{ + extract::{Path, State}, + http::HeaderMap, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sqlx::PgPool; + +use crate::draft; +use crate::draft_origin::Origin; +use crate::routes::ApiError; +use crate::state::{new_uuid, now_str, AppState, Project}; + +pub fn router() -> Router { + Router::new() + .route("/api/v1/projects/{uuid}/corrections", get(get_corrections)) + .route("/api/v1/projects/{uuid}/corrections/apply", post(apply)) + .route("/api/v1/projects/{uuid}/corrections/reject", post(reject)) + .route("/api/v1/projects/{uuid}/corrections/redo", post(redo)) +} + +pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS corrections ( + uuid TEXT PRIMARY KEY, + project_uuid TEXT NOT NULL, + state TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'repair', + persona TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', + files TEXT NOT NULL DEFAULT '[]', + sha TEXT, + deployment_uuid TEXT, + backup_id TEXT, + note TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + "#, + ) + .execute(pool) + .await?; + for col in [ + "attempts INTEGER NOT NULL DEFAULT 0", + "broke TEXT NOT NULL DEFAULT ''", + "changed TEXT NOT NULL DEFAULT ''", + "test_ok BOOLEAN NULL", + "test_text TEXT NOT NULL DEFAULT ''", + ] { + sqlx::query(&format!( + "ALTER TABLE corrections ADD COLUMN IF NOT EXISTS {col}" + )) + .execute(pool) + .await?; + } + sqlx::query( + "CREATE INDEX IF NOT EXISTS corrections_project ON corrections (project_uuid, updated_at)", + ) + .execute(pool) + .await?; + Ok(()) +} + +/// États ouverts (la correction est encore dans le dossier de travail). +const MAX_ATTEMPTS: i32 = 3; + +/// Personnage affiché pour une origine. +pub fn persona_for(kind: &str, agent_name: Option<&str>) -> String { + match kind { + "repair" => "Rustine".into(), + "feature" => "Braise".into(), + "routine" => "Phare".into(), + "manual" => "toi".into(), + "sync" => "DevForge".into(), + _ => agent_name.unwrap_or("DevForge").to_string(), + } +} + +/// Première phrase lisible d'une réponse d'agent (≤ 160 caractères). +pub fn one_sentence(reply: &str) -> String { + let flat = crate::mobile_routes::excerpt(reply, 400); + let mut out = String::new(); + for (i, c) in flat.char_indices() { + out.push(c); + if matches!(c, '.' | '!' | '?') + && flat[i + c.len_utf8()..].starts_with(' ') + && out.chars().count() > 20 + { + break; + } + } + let out = out.trim().to_string(); + if out.chars().count() > 160 { + format!("{}…", out.chars().take(159).collect::().trim_end()) + } else { + out + } +} + +/// Phrase par défaut quand l'agent n'a rien résumé. +pub fn default_summary(files: &[String]) -> String { + match files.len() { + 0 => "Petite correction.".into(), + 1 => format!("Modifie {}.", short(&files[0])), + n => format!( + "Modifie {} et {} autre{}.", + short(&files[0]), + n - 1, + if n > 2 { "s" } else { "" } + ), + } +} + +fn short(p: &str) -> &str { + p.rsplit('/').next().unwrap_or(p) +} + +#[derive(Debug, Clone, sqlx::FromRow)] +struct Row { + uuid: String, + state: String, + kind: String, + persona: String, + summary: String, + files: String, + sha: Option, + deployment_uuid: Option, + backup_id: Option, + note: String, + created_at: String, + updated_at: String, + attempts: i32, + broke: String, + changed: String, + test_ok: Option, + test_text: String, +} + +async fn open_row(pool: &PgPool, project_uuid: &str) -> Option { + sqlx::query_as::<_, Row>( + "SELECT uuid, state, kind, persona, summary, files, sha, deployment_uuid, backup_id, note, created_at, updated_at, attempts, broke, changed, test_ok, test_text + FROM corrections WHERE project_uuid = $1 AND state IN ('testing', 'proposed', 'conflict') ORDER BY updated_at DESC LIMIT 1", + ) + .bind(project_uuid) + .fetch_optional(pool) + .await + .ok() + .flatten() +} + +async fn set_state(pool: &PgPool, uuid: &str, state: &str, note: &str) { + let _ = sqlx::query( + "UPDATE corrections SET state = $1, note = $2, updated_at = $3 WHERE uuid = $4", + ) + .bind(state) + .bind(note) + .bind(now_str()) + .bind(uuid) + .execute(pool) + .await; +} + +/// Rapport structuré écrit par l'agent en fin de réponse : +/// `Cassé : …` / `Changé : …` (variantes « Ce qui a cassé », « Ce que j'ai changé »). +pub fn parse_report(reply: &str) -> (String, String) { + let mut broke = String::new(); + let mut changed = String::new(); + for line in reply.lines() { + let l = line + .trim() + .trim_start_matches(['-', '*', '•', ' ']) + .replace("**", ""); + let low = l.to_lowercase(); + let take = |l: &str| { + l.splitn(2, ':') + .nth(1) + .unwrap_or("") + .trim() + .chars() + .take(240) + .collect::() + }; + if broke.is_empty() && (low.starts_with("cassé") || low.starts_with("ce qui a cassé")) { + broke = take(&l); + } else if changed.is_empty() + && (low.starts_with("changé") + || low.starts_with("ce que j'ai changé") + || low.starts_with("ce que j’ai changé")) + { + changed = take(&l); + } + } + (broke, changed) +} + +/// Ce que l'utilisateur doit faire, selon l'étape. +pub fn todo_for(stage: &str) -> &'static str { + match stage { + "testing" => "Rien pour l’instant : Rustine vérifie sa correction.", + "proposed" => "Appuie sur Appliquer pour la mettre en ligne, ou Rejeter.", + "conflict" => "Demande à Rustine de refaire la correction.", + "deploying" => "Rien : la mise en ligne est en cours.", + "live" => "Rien : c’est en ligne.", + "failed" => "Ouvre la discussion avec Rustine pour décider de la suite.", + _ => "Rien.", + } +} + +/// Fin de run d'agent ayant touché des fichiers : la correction est créée ou complétée. +/// Une réparation passe d'abord par « testing » : l'utilisateur n'est prévenu qu'après un test réussi. +pub async fn note_proposed( + state: &AppState, + project_uuid: &str, + kind: &str, + agent_name: &str, + reply: &str, + files: &[String], +) { + let pool = &state.pool; + let now = now_str(); + let (mut broke, mut changed) = parse_report(reply); + let mut summary = if changed.is_empty() { + one_sentence(reply) + } else { + changed.clone() + }; + if summary.chars().count() < 8 { + summary = default_summary(files); + } + if changed.is_empty() { + changed = summary.clone(); + } + let persona = persona_for(kind, Some(agent_name)); + let next = if kind == "repair" { + "testing" + } else { + "proposed" + }; + let id = if let Some(r) = open_row(pool, project_uuid).await { + let mut all: Vec = serde_json::from_str(&r.files).unwrap_or_default(); + for f in files { + if !all.contains(f) { + all.push(f.clone()); + } + } + if broke.is_empty() { + broke = r.broke.clone(); + } + let _ = sqlx::query( + "UPDATE corrections SET state = $1, kind = $2, persona = $3, summary = $4, files = $5, note = '', broke = $6, changed = $7, + test_ok = NULL, test_text = '', updated_at = $8 WHERE uuid = $9", + ) + .bind(next) + .bind(kind) + .bind(&persona) + .bind(&summary) + .bind(json!(all).to_string()) + .bind(&broke) + .bind(&changed) + .bind(&now) + .bind(&r.uuid) + .execute(pool) + .await; + r.uuid + } else { + let id = new_uuid(); + let _ = sqlx::query( + "INSERT INTO corrections (uuid, project_uuid, state, kind, persona, summary, files, broke, changed, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)", + ) + .bind(&id) + .bind(project_uuid) + .bind(next) + .bind(kind) + .bind(&persona) + .bind(&summary) + .bind(json!(files).to_string()) + .bind(&broke) + .bind(&changed) + .bind(&now) + .execute(pool) + .await; + id + }; + if next == "testing" { + let st = state.clone(); + let p = project_uuid.to_string(); + spawn_verify(st, p, id); + } +} + +fn spawn_verify(st: AppState, p: String, id: String) { + let fut: std::pin::Pin + Send>> = + Box::pin(async move { verify(&st, &p, &id).await }); + tokio::spawn(fut); +} + +/// Vérifie la correction (tests/build déclarés + démarrage de l'aperçu qui doit répondre). +/// Réussite → « proposée » (notifiée). Échec → Rustine réessaie (3 fois max), puis échec clair. +pub async fn verify(state: &AppState, project_uuid: &str, id: &str) { + let tests = tokio::time::timeout( + Duration::from_secs(900), + state.registry.execute( + "run_application_tests", + json!({ "project_uuid": project_uuid }), + ), + ) + .await; + let (tests_ok, tests_txt, tests_err) = match tests { + Ok(Ok(v)) => { + let ok = v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false); + let err = v + .get("error") + .and_then(|e| e.as_str()) + .unwrap_or("") + .to_string(); + let none = + !ok && (err.contains("test_command") || err.to_lowercase().contains("aucun")); + if none { + (true, String::new(), String::new()) + } else if ok { + (true, "les tests passent".to_string(), String::new()) + } else { + let logs = v + .get("output") + .or_else(|| v.get("logs")) + .and_then(|l| l.as_str()) + .unwrap_or(""); + ( + false, + "les tests échouent".to_string(), + format!("{err}\n{}", tail(logs, 1500)), + ) + } + } + Ok(Err(e)) => ( + false, + "les tests n’ont pas pu tourner".to_string(), + e.to_string(), + ), + Err(_) => ( + false, + "les tests ont dépassé le temps".to_string(), + "timeout".into(), + ), + }; + let (smoke_ok, smoke_err) = if tests_ok { + let r = tokio::time::timeout( + Duration::from_secs(600), + state.registry.execute( + "start_local_preview", + json!({ "project_uuid": project_uuid, "force": true }), + ), + ) + .await; + match r { + Ok(Ok(v)) => { + let ok = v + .get("public_ok") + .and_then(|b| b.as_bool()) + .or_else(|| v.get("ok").and_then(|b| b.as_bool())) + .unwrap_or(false); + let err = v + .get("error") + .and_then(|e| e.as_str()) + .unwrap_or("") + .to_string(); + let logs = v.get("logs_tail").and_then(|l| l.as_str()).unwrap_or(""); + (ok, format!("{err}\n{}", tail(logs, 1500))) + } + Ok(Err(e)) => (false, e.to_string()), + Err(_) => (false, "timeout".into()), + } + } else { + (false, String::new()) + }; + let passed = tests_ok && smoke_ok; + let what = if tests_txt.is_empty() { + "build de l’app et démarrage de l’aperçu".to_string() + } else { + format!("{tests_txt}, build et démarrage de l’aperçu") + }; + let test_text = if passed { + format!("Réussi : {what}, l’app répond.") + } else if !tests_ok { + format!("Échoué : {tests_txt}.") + } else { + "Échoué : l’app ne démarre pas ou ne répond pas.".to_string() + }; + let attempts: i32 = sqlx::query_as::<_, (i32,)>("UPDATE corrections SET attempts = attempts + 1, test_ok = $1, test_text = $2, updated_at = $3 WHERE uuid = $4 RETURNING attempts") + .bind(passed) + .bind(&test_text) + .bind(now_str()) + .bind(id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .map(|(a,)| a) + .unwrap_or(MAX_ATTEMPTS); + if passed { + set_state(&state.pool, id, "proposed", "").await; + return; + } + let reason = if tests_ok { smoke_err } else { tests_err }; + if attempts >= MAX_ATTEMPTS { + set_state( + &state.pool, + id, + "failed", + &format!("Rustine n’a pas réussi à corriger après {attempts} essais. Le test échoue toujours."), + ) + .await; + return; + } + // Nouvel essai : on garde les fichiers, Rustine repart du résultat du test. + let agent: Option<(String,)> = sqlx::query_as( + "SELECT agent_uuid FROM draft_origins WHERE project_uuid = $1 AND kind = 'repair' AND agent_uuid <> '' ORDER BY recorded_at DESC LIMIT 1", + ) + .bind(project_uuid) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + let Some((agent_uuid,)) = agent else { + set_state( + &state.pool, + id, + "failed", + "Le test de la correction échoue.", + ) + .await; + return; + }; + let prompt = format!( + "{REDO_PREFIX}\nAUTO-RÉPARATION\nTa correction ne passe pas le test (essai {attempts}/{MAX_ATTEMPTS}).\n{test_text}\nDétail :\n```\n{}\n```\nCorrige en local (sans publier, sans sync_workdir_to_github ni trigger_deploy), relance le build, puis termine par le rapport :\nCassé : …\nChangé : …", + tail(&crate::draft::redact(&reason), 2500) + ); + if crate::agent_runs::record_user_turn(&state.pool, project_uuid, &agent_uuid, &prompt) + .await + .is_ok() + { + let (st, p) = (state.clone(), project_uuid.to_string()); + let fut: std::pin::Pin + Send>> = + Box::pin(async move { + let _ = crate::routes::trigger_agent_turn(&st, &p, &agent_uuid).await; + }); + tokio::spawn(fut); + } +} + +fn tail(s: &str, max: usize) -> String { + let n = s.chars().count(); + if n <= max { + s.trim().to_string() + } else { + s.chars() + .skip(n - max) + .collect::() + .trim() + .to_string() + } +} + +/// Agent de réparation (Rustine) de l'app, créé au besoin. Pas de déclencheur d'événement : +/// seul le serveur le réveille, avec son propre prompt. +pub async fn ensure_repair_agent(pool: &PgPool, project_uuid: &str) -> Result { + if let Ok(Some((u,))) = sqlx::query_as::<_, (String,)>( + "SELECT uuid FROM project_agents WHERE project_uuid = $1 AND role = 'repair' ORDER BY created_at LIMIT 1", + ) + .bind(project_uuid) + .fetch_optional(pool) + .await + { + return Ok(u); + } + let uuid = new_uuid(); + let now = now_str(); + sqlx::query( + r#"INSERT INTO project_agents ( + uuid, project_uuid, name, role, kind, parent_agent_uuid, status, + enabled, trigger_type, trigger_config, instructions, last_run_at, next_run_at, created_at, updated_at + ) VALUES ($1, $2, 'Rustine', 'repair', 'required', NULL, 'idle', 1, '', '{}', '', '', '', $3, $3)"#, + ) + .bind(&uuid) + .bind(project_uuid) + .bind(&now) + .execute(pool) + .await + .map_err(|e| e.to_string())?; + Ok(uuid) +} + +/// Une réparation est ouverte (en test, prête ou en conflit) : le dossier de travail ne doit pas être remis à zéro. +pub async fn has_open_repair(pool: &PgPool, project_uuid: &str) -> bool { + sqlx::query_as::<_, (i64,)>( + "SELECT COUNT(*) FROM corrections WHERE project_uuid = $1 AND kind = 'repair' AND state IN ('testing', 'proposed', 'conflict')", + ) + .bind(project_uuid) + .fetch_one(pool) + .await + .map(|(n,)| n > 0) + .unwrap_or(false) +} + +/// Échec dû à la plateforme (délais Docker, conteneur jamais prêt sans erreur de l'app). +pub fn platform_failure(summary: &str, logs: &str) -> Option<&'static str> { + let s = format!("{summary}\n{logs}").to_lowercase(); + if s.contains("docker_update_labels") || (s.contains("update labels") && s.contains("timeout")) + { + return Some("La plateforme DevForge n’a pas réussi à mettre à jour le conteneur à temps (délai Docker)."); + } + if s.contains("wait_container_ready") + || s.contains("conteneur pas prêt") + || s.contains("container not ready") + { + let app_error = [ + "error:", + "exception", + "panic", + "cannot find module", + "syntaxerror", + "typeerror", + "referenceerror", + ] + .iter() + .any(|k| s.contains(k)); + if !app_error { + return Some("Le conteneur n’a pas répondu à temps, sans erreur de l’app : problème probable de la plateforme DevForge."); + } + } + None +} + +/// Échec de plateforme : une ligne claire dans Corrections, sans agent. +pub async fn note_platform_failure(pool: &PgPool, project_uuid: &str, dep_uuid: &str, text: &str) { + let now = now_str(); + let _ = sqlx::query( + "INSERT INTO corrections (uuid, project_uuid, state, kind, persona, summary, files, deployment_uuid, note, broke, changed, test_text, created_at, updated_at) + VALUES ($1, $2, 'failed', 'platform', 'DevForge', 'Problème de la plateforme, pas de ton app.', '[]', $3, $4, $4, 'Rien dans ton app.', '', $5, $5)", + ) + .bind(new_uuid()) + .bind(project_uuid) + .bind(dep_uuid) + .bind(text) + .bind(&now) + .execute(pool) + .await; +} + +/// Étapes affichées (stepper). +pub fn steps(stage: &str, problem: bool, tested: bool) -> Value { + let order = ["detected", "proposed", "tested", "live"]; + let reached = match stage { + "live" => 4, + "deploying" => 3, + "failed" => 3, + "testing" => 2, + _ => { + if tested { + 3 + } else { + 2 + } + } + }; + let labels = [ + "Problème détecté", + "Correction proposée", + "Testée", + "En ligne", + ]; + let mut out = Vec::new(); + for (i, k) in order.iter().enumerate() { + if i == 0 && !problem { + continue; + } + let state = if i < reached { + "done" + } else if i == reached { + match stage { + "failed" | "conflict" => "failed", + "deploying" | "testing" => "active", + "rejected" | "archived" => "skipped", + _ => "todo", + } + } else { + "todo" + }; + out.push(json!({ "key": k, "label": labels[i], "state": state })); + } + json!(out) +} + +/// Phrase de statut, sans jargon. +pub fn stage_label(stage: &str) -> &'static str { + match stage { + "testing" => "Test en cours", + "proposed" => "Prête", + "conflict" => "À refaire", + "deploying" => "Mise en ligne…", + "live" => "En ligne", + "failed" => "Échec", + "rejected" => "Rejetée", + "archived" => "Archivée", + _ => "", + } +} + +fn data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(std::env::var("DEVFORGE_DATA_DIR").unwrap_or_else(|_| "/data".into())) +} + +async fn project_ctx( + state: &AppState, + headers: &HeaderMap, + uuid: &str, +) -> Result< + ( + crate::auth_routes::UserRow, + Project, + std::path::PathBuf, + String, + ), + ApiError, +> { + let (user, _ws, project) = crate::routes::auth_project(state, headers, uuid).await?; + let wd = crate::draft_routes::workdir_of(&project) + .ok_or_else(|| ApiError::message("Cette app n'a pas de dossier de travail."))?; + let br = crate::draft_routes::branch_of(&project); + Ok((user, project, wd, br)) +} + +async fn remote(state: &AppState, user_uuid: &str, project: &Project) -> Option { + let repo = project + .git_repository + .as_deref() + .filter(|s| !s.trim().is_empty())?; + let mut token = crate::user_prefs::github_token(&state.pool, user_uuid).await; + if token.trim().is_empty() { + token = state.github.instance_token().unwrap_or_default(); + } + Some(draft::token_url(repo, Some(&token))) +} + +/// Met à jour les corrections en cours de mise en ligne d'après les déploiements. +async fn refresh_deploying(pool: &PgPool, project: &Project) { + let rows: Vec<(String, Option)> = sqlx::query_as( + "SELECT uuid, sha FROM corrections WHERE project_uuid = $1 AND state = 'deploying'", + ) + .bind(&project.uuid) + .fetch_all(pool) + .await + .unwrap_or_default(); + for (uuid, sha) in rows { + let Some(sha) = sha else { continue }; + let short: String = sha.chars().take(7).collect(); + let dep: Option<(String, String)> = sqlx::query_as( + "SELECT uuid, status FROM deployments WHERE project_id = $1 AND (target_sha = $2 OR git_sha LIKE $3) + ORDER BY created_at DESC LIMIT 1", + ) + .bind(project.id) + .bind(&sha) + .bind(format!("{short}%")) + .fetch_optional(pool) + .await + .ok() + .flatten(); + if let Some((dep_uuid, status)) = dep { + let next = match status.as_str() { + "success" | "deployed" | "ok" => Some(("live", "")), + "failed" | "error" => Some(( + "failed", + "La mise en ligne a échoué. Rustine peut regarder.", + )), + "cancelled" => Some(("failed", "Mise en ligne annulée.")), + _ => None, + }; + if let Some((st, note)) = next { + let _ = sqlx::query("UPDATE corrections SET state = $1, note = $2, deployment_uuid = $3, updated_at = $4 WHERE uuid = $5") + .bind(st) + .bind(note) + .bind(&dep_uuid) + .bind(now_str()) + .bind(&uuid) + .execute(pool) + .await; + } + } + } +} + +/// Archivage automatique : correction déjà sur GitHub, réparation dépassée, ou seulement des lockfiles. +/// Le contenu est sauvegardé 7 jours puis retiré du dossier de travail. +pub async fn auto_sort(state: &AppState, project: &Project) -> Option { + let wd = crate::draft_routes::workdir_of(project)?; + let br = crate::draft_routes::branch_of(project); + let (wd2, br2) = (wd.clone(), br.clone()); + let (st, insight) = tokio::task::spawn_blocking(move || { + let st = draft::status(&wd2, &br2, None); + let insight = st.dirty.then(|| draft::remote_insight(&wd2, &br2, &st)); + (st, insight) + }) + .await + .ok()?; + if !st.available || !st.dirty { + // Rien en local : une correction « ouverte » n'a plus d'objet. + if let Some(r) = open_row(&state.pool, &project.uuid).await { + set_state( + &state.pool, + &r.uuid, + "archived", + "Plus rien à appliquer : déjà intégrée ou retirée.", + ) + .await; + } + return None; + } + let known = crate::draft_origin::load(&state.pool, &project.uuid).await; + let origins: Vec = crate::draft_origin::per_file(&st.files, &known); + let obsolete = crate::draft_origin::repair_obsolete_since(&state.pool, &origins).await; + let age = st + .updated_at + .as_deref() + .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()) + .map(|t| { + (chrono::Utc::now() - t.with_timezone(&chrono::Utc)) + .num_days() + .max(0) + }); + let advice = draft::advise(&draft::AdviceInput { + count: st.files.len(), + insight: insight.as_ref(), + age_days: age, + lockfile_only: st.files.iter().all(|f| draft::is_lockfile(&f.path)), + repair_obsolete_since: obsolete, + }); + if advice.level != "delete" { + return None; + } + let root = draft::backups_root(&data_dir(), &project.uuid); + let files = st.files.clone(); + let n = files.len(); + let res = tokio::task::spawn_blocking(move || { + let b = draft::backup(&wd, &root, &files, "auto-archive")?; + draft::discard(&wd, &br, None)?; + Ok::<_, draft::DraftError>(b) + }) + .await + .ok()?; + let b = res.ok()?; + crate::draft_origin::forget(&state.pool, &project.uuid, None).await; + let note = format!("Archivée automatiquement : {}", advice.reason); + match open_row(&state.pool, &project.uuid).await { + Some(r) => { + let _ = sqlx::query("UPDATE corrections SET state = 'archived', backup_id = $1, note = $2, updated_at = $3 WHERE uuid = $4") + .bind(&b.id) + .bind(¬e) + .bind(now_str()) + .bind(&r.uuid) + .execute(&state.pool) + .await; + } + None => { + let paths: Vec = st.files.iter().map(|f| f.path.clone()).collect(); + let kind = crate::draft_origin::summary(&origins) + .map(|o| o.kind) + .unwrap_or_else(|| "manual".into()); + let now = now_str(); + let _ = sqlx::query( + "INSERT INTO corrections (uuid, project_uuid, state, kind, persona, summary, files, backup_id, note, created_at, updated_at) + VALUES ($1, $2, 'archived', $3, $4, $5, $6, $7, $8, $9, $9)", + ) + .bind(new_uuid()) + .bind(&project.uuid) + .bind(&kind) + .bind(persona_for(&kind, None)) + .bind(default_summary(&paths)) + .bind(json!(paths).to_string()) + .bind(&b.id) + .bind(¬e) + .bind(&now) + .execute(&state.pool) + .await; + } + } + crate::deploy_queue::record_event( + &state.pool, + &project.uuid, + "draft", + "archived", + &b.id, + &format!("{n} fichier(s) archivés automatiquement, sauvegarde 7 jours"), + ) + .await; + Some(b.id) +} + +/// Boucle de tri automatique (toutes les 10 min, sans réseau). +pub async fn run_loop(state: AppState) { + tokio::time::sleep(Duration::from_secs(120)).await; + loop { + let projects = sqlx::query_as::<_, Project>("SELECT * FROM projects") + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + for p in &projects { + refresh_deploying(&state.pool, p).await; + if crate::draft_routes::workdir_of(p) + .map(|w| w.is_dir()) + .unwrap_or(false) + { + auto_sort(&state, p).await; + } + } + tokio::time::sleep(Duration::from_secs(600)).await; + } +} + +fn report_json(r: &Row) -> Value { + let files: Vec = serde_json::from_str(&r.files).unwrap_or_default(); + json!({ + "broke": r.broke, + "changed": if r.changed.is_empty() { r.summary.clone() } else { r.changed.clone() }, + "files": files, + "test_ok": r.test_ok, + "test": if r.test_text.is_empty() { if r.state == "testing" { "En cours…".to_string() } else { "Pas encore testée.".to_string() } } else { r.test_text.clone() }, + "todo": todo_for(&r.state), + }) +} + +fn row_json(r: &Row) -> Value { + let files: Vec = serde_json::from_str(&r.files).unwrap_or_default(); + let problem = r.kind == "repair"; + json!({ + "id": r.uuid, + "stage": r.state, + "status_label": stage_label(&r.state), + "persona": r.persona, + "summary": r.summary, + "files": files, + "note": r.note, + "sha": r.sha.as_deref().map(|s| s.chars().take(7).collect::()), + "deployment_uuid": r.deployment_uuid, + "backup_id": r.backup_id, + "steps": steps(&r.state, problem, r.test_ok.unwrap_or(false)), + "report": report_json(r), + "created_at": r.created_at, + "updated_at": r.updated_at, + }) +} + +async fn get_corrections( + State(state): State, + headers: HeaderMap, + Path(uuid): Path, +) -> Result, ApiError> { + let (_u, project, wd, br) = project_ctx(&state, &headers, &uuid).await?; + refresh_deploying(&state.pool, &project).await; + auto_sort(&state, &project).await; + let (wd2, br2) = (wd.clone(), br.clone()); + let st = tokio::task::spawn_blocking(move || draft::status(&wd2, &br2, None)) + .await + .map_err(|e| ApiError::message(e.to_string()))?; + let mut current = Value::Null; + if st.available && st.dirty { + let known = crate::draft_origin::load(&state.pool, &uuid).await; + let origins = crate::draft_origin::per_file(&st.files, &known); + let main = crate::draft_origin::summary(&origins).unwrap_or_default(); + let paths: Vec = st.files.iter().map(|f| f.path.clone()).collect(); + let row = open_row(&state.pool, &uuid).await; + let preview = tokio::time::timeout( + Duration::from_secs(3), + state + .registry + .execute("local_preview_status", json!({ "project_uuid": uuid })), + ) + .await; + let tested = row.as_ref().and_then(|r| r.test_ok).unwrap_or(false) + || matches!(preview, Ok(Ok(ref v)) if matches!(v.get("status").and_then(|s| s.as_str()), Some("running"))); + let problem = main.kind == "repair" && main.deployment_uuid.is_some(); + let stage = row + .as_ref() + .map(|r| r.state.clone()) + .unwrap_or_else(|| "proposed".into()); + let persona = row + .as_ref() + .map(|r| r.persona.clone()) + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| persona_for(&main.kind, main.agent_name.as_deref())); + let summary = row + .as_ref() + .map(|r| r.summary.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| default_summary(&paths)); + current = json!({ + "id": row.as_ref().map(|r| r.uuid.clone()).unwrap_or_else(|| "local".into()), + "stage": stage, + "status_label": stage_label(&stage), + "persona": persona, + "summary": summary, + "files": paths, + "message": draft::suggested_message(&st.files), + "note": row.as_ref().map(|r| r.note.clone()).unwrap_or_default(), + "tested": tested, + "report": match row.as_ref() { + Some(r) => report_json(r), + None => json!({ "broke": "", "changed": default_summary(&paths), "files": paths, "test_ok": null, + "test": "Pas encore testée.", "todo": todo_for("proposed") }), + }, + "steps": steps(&stage, problem, tested), + "run_uuid": main.run_uuid, + "agent_uuid": main.agent_uuid, + "updated_at": st.updated_at, + }); + } + let history: Vec = sqlx::query_as::<_, Row>( + "SELECT uuid, state, kind, persona, summary, files, sha, deployment_uuid, backup_id, note, created_at, updated_at, attempts, broke, changed, test_ok, test_text + FROM corrections WHERE project_uuid = $1 AND state NOT IN ('testing', 'proposed', 'conflict') ORDER BY updated_at DESC LIMIT 8", + ) + .bind(&uuid) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + let root = draft::backups_root(&data_dir(), &uuid); + let backups = tokio::task::spawn_blocking(move || draft::list_backups(&root)) + .await + .unwrap_or_default(); + let restorable: Vec = backups.iter().map(|b| b.id.clone()).collect(); + let history: Vec = history + .iter() + .map(|r| { + let mut v = row_json(r); + v["restorable"] = json!(r + .backup_id + .as_ref() + .map(|b| restorable.contains(b)) + .unwrap_or(false)); + v + }) + .collect(); + Ok(Json( + json!({ "ok": true, "current": current, "history": history }), + )) +} + +#[derive(Deserialize, Default)] +struct ConfirmBody { + confirm: Option, +} + +async fn ensure_row( + state: &AppState, + project_uuid: &str, + files: &[String], + kind: &str, + persona: &str, +) -> String { + if let Some(r) = open_row(&state.pool, project_uuid).await { + return r.uuid; + } + let id = new_uuid(); + let now = now_str(); + let _ = sqlx::query( + "INSERT INTO corrections (uuid, project_uuid, state, kind, persona, summary, files, created_at, updated_at) + VALUES ($1, $2, 'proposed', $3, $4, $5, $6, $7, $7)", + ) + .bind(&id) + .bind(project_uuid) + .bind(kind) + .bind(persona) + .bind(default_summary(files)) + .bind(json!(files).to_string()) + .bind(&now) + .execute(&state.pool) + .await; + id +} + +async fn current_files(wd: &std::path::Path, br: &str) -> Vec { + let (wd, br) = (wd.to_path_buf(), br.to_string()); + tokio::task::spawn_blocking(move || { + draft::status(&wd, &br, None) + .files + .into_iter() + .map(|f| f.path) + .collect() + }) + .await + .unwrap_or_default() +} + +async fn origin_main(state: &AppState, uuid: &str, wd: &std::path::Path, br: &str) -> Origin { + let (wd, br) = (wd.to_path_buf(), br.to_string()); + let files = tokio::task::spawn_blocking(move || draft::status(&wd, &br, None).files) + .await + .unwrap_or_default(); + let known = crate::draft_origin::load(&state.pool, uuid).await; + crate::draft_origin::summary(&crate::draft_origin::per_file(&files, &known)).unwrap_or_default() +} + +async fn apply( + State(state): State, + headers: HeaderMap, + Path(uuid): Path, + Json(body): Json, +) -> Result, ApiError> { + if !body.confirm.unwrap_or(false) { + return Err(ApiError::message( + "Confirmation requise pour appliquer la correction.", + )); + } + let (user, project, wd, br) = project_ctx(&state, &headers, &uuid).await?; + let Some(url) = remote(&state, &user.uuid, &project).await else { + return Err(ApiError::message( + "Cette app n'est pas reliée à un dépôt GitHub.", + )); + }; + let files = current_files(&wd, &br).await; + let main = origin_main(&state, &uuid, &wd, &br).await; + let id = ensure_row( + &state, + &uuid, + &files, + &main.kind, + &persona_for(&main.kind, main.agent_name.as_deref()), + ) + .await; + let summary: String = + sqlx::query_as::<_, (String,)>("SELECT summary FROM corrections WHERE uuid = $1") + .bind(&id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .map(|(s,)| s) + .unwrap_or_default(); + let message = { + let (wd2, br2) = (wd.clone(), br.clone()); + let suggested = tokio::task::spawn_blocking(move || { + draft::suggested_message(&draft::status(&wd2, &br2, None).files) + }) + .await + .unwrap_or_default(); + if summary.trim().is_empty() { + suggested + } else { + format!( + "fix: {}", + summary + .trim_end_matches('.') + .chars() + .take(70) + .collect::() + ) + } + }; + let name = if user.name.trim().is_empty() { + "DevForge".to_string() + } else { + user.name.clone() + }; + let email = if user.email.trim().is_empty() { + "devforge@localhost".to_string() + } else { + user.email.clone() + }; + let (wd2, br2, msg2) = (wd.clone(), br.clone(), message.clone()); + let res = + tokio::task::spawn_blocking(move || draft::apply(&wd2, &br2, &url, &msg2, (&name, &email))) + .await + .map_err(|e| ApiError::message(e.to_string()))? + .map_err(|e| ApiError::message(draft::redact(&e.0)))?; + let pushed = match res { + draft::Applied::Conflict(reason) => { + set_state(&state.pool, &id, "conflict", &reason).await; + crate::deploy_queue::record_event( + &state.pool, + &uuid, + "draft", + "conflict", + &id, + &reason, + ) + .await; + return Ok(Json( + json!({ "ok": false, "conflict": true, "message": reason, "can_redo": true }), + )); + } + draft::Applied::Pushed(v) => v, + }; + crate::draft_origin::forget(&state.pool, &uuid, None).await; + let _ = sqlx::query("UPDATE corrections SET sha = $1, updated_at = $2 WHERE uuid = $3") + .bind(&pushed.sha) + .bind(now_str()) + .bind(&id) + .execute(&state.pool) + .await; + crate::deploy_queue::record_event( + &state.pool, + &uuid, + "draft", + "validated", + &pushed.sha, + &format!("correction appliquée ({} fichier(s))", pushed.files), + ) + .await; + // Jamais de relance automatique d'un SHA déjà en échec. + let history = + crate::auto_deploy_guard::load_history(&state.pool, project.id, "production").await; + let already_failed = history.iter().any(|a| { + matches!(a.status.as_str(), "failed" | "error") + && a.sha + .as_deref() + .map(|s| crate::auto_deploy_guard::same_sha(s, &pushed.sha)) + .unwrap_or(false) + }); + if already_failed { + set_state( + &state.pool, + &id, + "failed", + "Cette version a déjà échoué en ligne : pas de nouvelle tentative automatique.", + ) + .await; + return Ok(Json( + json!({ "ok": true, "deployed": false, "stage": "failed" }), + )); + } + set_state(&state.pool, &id, "deploying", "").await; + let st2 = state.clone(); + let sha = pushed.sha.clone(); + let id2 = id.clone(); + let deploy_msg = message.clone(); + tokio::spawn(async move { + crate::auto_deploy::deploy_project(&st2, &project, &deploy_msg, Some(&sha)).await; + refresh_deploying(&st2.pool, &project).await; + let _ = id2; + }); + Ok(Json( + json!({ "ok": true, "deployed": true, "stage": "deploying", "id": id }), + )) +} + +async fn discard_with_backup( + wd: &std::path::Path, + br: &str, + project_uuid: &str, + reason: &'static str, +) -> Result { + let root = draft::backups_root(&data_dir(), project_uuid); + let (wd, br) = (wd.to_path_buf(), br.to_string()); + tokio::task::spawn_blocking(move || { + let st = draft::status(&wd, &br, None); + if !st.dirty { + return Err(draft::DraftError("Aucune correction en attente.".into())); + } + let b = draft::backup(&wd, &root, &st.files, reason)?; + draft::discard(&wd, &br, None)?; + Ok(b.id) + }) + .await + .map_err(|e| ApiError::message(e.to_string()))? + .map_err(|e| ApiError::message(draft::redact(&e.0))) +} + +async fn reject( + State(state): State, + headers: HeaderMap, + Path(uuid): Path, + Json(body): Json, +) -> Result, ApiError> { + if !body.confirm.unwrap_or(false) { + return Err(ApiError::message( + "Confirmation requise pour rejeter la correction.", + )); + } + let (_user, _project, wd, br) = project_ctx(&state, &headers, &uuid).await?; + let files = current_files(&wd, &br).await; + let main = origin_main(&state, &uuid, &wd, &br).await; + let id = ensure_row( + &state, + &uuid, + &files, + &main.kind, + &persona_for(&main.kind, main.agent_name.as_deref()), + ) + .await; + let backup = discard_with_backup(&wd, &br, &uuid, "reject").await?; + crate::draft_origin::forget(&state.pool, &uuid, None).await; + let _ = sqlx::query("UPDATE corrections SET state = 'rejected', backup_id = $1, note = 'Rejetée. Récupérable pendant 7 jours.', updated_at = $2 WHERE uuid = $3") + .bind(&backup) + .bind(now_str()) + .bind(&id) + .execute(&state.pool) + .await; + crate::deploy_queue::record_event( + &state.pool, + &uuid, + "draft", + "discarded", + &backup, + "correction rejetée, sauvegarde 7 jours", + ) + .await; + Ok(Json(json!({ "ok": true, "backup_id": backup }))) +} + +pub const REDO_PREFIX: &str = "CORRECTION-REDO"; + +async fn redo( + State(state): State, + headers: HeaderMap, + Path(uuid): Path, + Json(body): Json, +) -> Result, ApiError> { + if !body.confirm.unwrap_or(false) { + return Err(ApiError::message("Confirmation requise.")); + } + let (_user, _project, wd, br) = project_ctx(&state, &headers, &uuid).await?; + let main = origin_main(&state, &uuid, &wd, &br).await; + let row = open_row(&state.pool, &uuid).await; + let summary = row.as_ref().map(|r| r.summary.clone()).unwrap_or_default(); + let backup = discard_with_backup(&wd, &br, &uuid, "redo").await.ok(); + crate::draft_origin::forget(&state.pool, &uuid, None).await; + if let Some(r) = &row { + let _ = sqlx::query("UPDATE corrections SET state = 'archived', backup_id = $1, note = 'Remplacée : Rustine refait la correction.', updated_at = $2 WHERE uuid = $3") + .bind(&backup) + .bind(now_str()) + .bind(&r.uuid) + .execute(&state.pool) + .await; + } + let agent: Option<(String,)> = match main.agent_uuid.clone() { + Some(a) => Some((a,)), + None => sqlx::query_as("SELECT uuid FROM project_agents WHERE project_uuid = $1 AND role = 'coordinator' ORDER BY created_at LIMIT 1") + .bind(&uuid) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(), + }; + let Some((agent_uuid,)) = agent else { + return Err(ApiError::message( + "Aucun agent disponible pour refaire la correction.", + )); + }; + let prompt = format!( + "{REDO_PREFIX}\nAUTO-RÉPARATION\nLa correction précédente ne s'appliquait plus sur le code à jour (conflit). Elle a été mise de côté (sauvegarde 7 jours).\nRefais la même correction à partir du code actuel, en local, sans publier.\nRésumé de la correction : {summary}" + ); + crate::agent_runs::record_user_turn(&state.pool, &uuid, &agent_uuid, &prompt) + .await + .map_err(ApiError::message)?; + let st = state.clone(); + let (p, a) = (uuid.clone(), agent_uuid.clone()); + tokio::spawn(async move { + if let Err(e) = crate::routes::trigger_agent_turn(&st, &p, &a).await { + tracing::warn!(error = %e, "correction redo"); + } + }); + Ok(Json(json!({ "ok": true, "agent_uuid": agent_uuid }))) +} + +/// Événements de la boîte de réception mobile (proposée, en ligne, échec). +pub async fn inbox_events( + pool: &PgPool, + projects: &[Project], + since: &str, +) -> Vec { + let mut out = Vec::new(); + for p in projects { + let rows: Vec<(String, String, String, String, String, String)> = sqlx::query_as( + "SELECT uuid, state, persona, summary, note, updated_at FROM corrections + WHERE project_uuid = $1 AND updated_at > $2 AND (state IN ('live', 'failed', 'conflict') OR (state = 'proposed' AND kind = 'repair' AND test_ok = TRUE)) + ORDER BY updated_at DESC LIMIT 5", + ) + .bind(&p.uuid) + .bind(since) + .fetch_all(pool) + .await + .unwrap_or_default(); + for (id, st, persona, summary, note, at) in rows { + let (kind, title, body) = match st.as_str() { + "proposed" => ( + "correction_proposed", + format!("Correction prête · {}", p.name), + format!("{persona} : {summary} Testée. Appuie sur Appliquer."), + ), + "live" => ( + "correction_live", + format!("Correction en ligne · {}", p.name), + summary.clone(), + ), + _ => ( + "correction_failed", + format!("Correction en échec · {}", p.name), + if note.is_empty() { + summary.clone() + } else { + note.clone() + }, + ), + }; + out.push(crate::mobile_routes::InboxEvent { + id: format!("correction:{id}:{st}"), + kind, + project_uuid: p.uuid.clone(), + project_name: p.name.clone(), + title, + body, + created_at: at, + }); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sentence_and_summary() { + assert_eq!( + one_sentence("J'ai corrigé le port du serveur. Ensuite j'ai relancé."), + "J'ai corrigé le port du serveur." + ); + assert_eq!(default_summary(&["src/a/b.rs".into()]), "Modifie b.rs."); + assert_eq!( + default_summary(&["a.rs".into(), "b.rs".into(), "c.rs".into()]), + "Modifie a.rs et 2 autres." + ); + } + + #[test] + fn platform_detection() { + assert!(platform_failure("docker_update_labels timeout", "").is_some()); + assert!(platform_failure("wait_container_ready 90s", "listening").is_some()); + assert!(platform_failure("wait_container_ready", "Error: Cannot find module x").is_none()); + assert!(platform_failure("npm ci failed", "").is_none()); + } + + #[test] + fn steps_states() { + let s = steps("proposed", true, false); + assert_eq!(s.as_array().unwrap().len(), 4); + assert_eq!(s[1]["state"], "done"); + assert_eq!(s[2]["state"], "todo"); + let s = steps("failed", false, true); + assert_eq!(s.as_array().unwrap().len(), 3); + assert_eq!(s[2]["state"], "failed"); + let s = steps("live", true, true); + assert!(s.as_array().unwrap().iter().all(|x| x["state"] == "done")); + assert_eq!(persona_for("repair", None), "Rustine"); + assert!(stage_label("proposed").len() > 0); + } +} diff --git a/apps/server/src/db.rs b/apps/server/src/db.rs index 586e0db4e2..5a4435c009 100644 --- a/apps/server/src/db.rs +++ b/apps/server/src/db.rs @@ -305,6 +305,7 @@ pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::Error> { .await?; crate::draft_origin::migrate(pool).await?; + crate::correction::migrate(pool).await?; sqlx::query( r#" diff --git a/apps/server/src/draft.rs b/apps/server/src/draft.rs index 22073ddfb8..b60f76c258 100644 --- a/apps/server/src/draft.rs +++ b/apps/server/src/draft.rs @@ -555,10 +555,38 @@ pub fn validate( if st.behind > 1 { "s" } else { "" } ))); } + commit_files(workdir, &st.files, message, author)?; + let dest = format!("HEAD:refs/heads/{branch}"); + git(workdir, &["push", "--quiet", push_url, &dest]).map_err(|e| { + let m = redact(&e.0); + if m.contains("non-fast-forward") || m.contains("rejected") || m.contains("fetch first") { + err("GitHub a avancé pendant ce temps. Mets à jour depuis GitHub, puis valide à nouveau.") + } else { + err(format!("Envoi sur GitHub impossible : {m}")) + } + })?; + let _ = git(workdir, &["update-ref", &remote_ref(branch), "HEAD"]); + let sha = git(workdir, &["rev-parse", "--short=12", "HEAD"])? + .trim() + .to_string(); + Ok(Validated { + sha, + files: st.files.len(), + branch: branch.to_string(), + }) +} + +/// Indexe uniquement `files` (bruit exclu) et crée un commit. `true` si un commit a été créé. +fn commit_files( + workdir: &Path, + files: &[DraftFile], + message: &str, + author: (&str, &str), +) -> R { ensure_local_excludes(workdir); let gd = git_dir(workdir)?; let mut spec = Vec::new(); - for f in &st.files { + for f in files { spec.extend_from_slice(f.path.as_bytes()); spec.push(0); if let Some(old) = &f.old_path { @@ -596,24 +624,109 @@ pub fn validate( } let _ = std::fs::remove_file(&spec_path); let _ = std::fs::remove_file(&msg_path); - let dest = format!("HEAD:refs/heads/{branch}"); - git(workdir, &["push", "--quiet", push_url, &dest]).map_err(|e| { - let m = redact(&e.0); - if m.contains("non-fast-forward") || m.contains("rejected") || m.contains("fetch first") { - err("GitHub a avancé pendant ce temps. Mets à jour depuis GitHub, puis valide à nouveau.") - } else { - err(format!("Envoi sur GitHub impossible : {m}")) + Ok(staged) +} + +/// Résultat d'« Appliquer » : envoyé, ou conflit réel (le brouillon est remis tel quel). +#[derive(Debug)] +pub enum Applied { + Pushed(Validated), + Conflict(String), +} + +/// « Appliquer » : commit local, rattrapage automatique de GitHub (rebase), push. +/// En cas de conflit réel, tout est annulé et les fichiers restent en local. +pub fn apply( + workdir: &Path, + branch: &str, + push_url: &str, + message: &str, + author: (&str, &str), +) -> R { + let message = message.trim(); + let message = if message.is_empty() { + "fix: correction DevForge" + } else { + message + }; + let st = status(workdir, branch, Some(push_url)); + if !st.available { + return Err(err(st.reason.unwrap_or_default())); + } + if !st.dirty && st.ahead == 0 { + return Err(err("Rien à appliquer : aucune correction en attente.")); + } + let before = git(workdir, &["rev-parse", "HEAD"])?.trim().to_string(); + let committed = if st.dirty { + commit_files(workdir, &st.files, message, author)? + } else { + false + }; + let name = format!("user.name={}", author.0); + let email = format!("user.email={}", author.1); + let undo = |reason: String| -> R { + let _ = git(workdir, &["rebase", "--abort"]); + if committed { + let _ = git(workdir, &["reset", "--quiet", "--mixed", &before]); } - })?; - let _ = git(workdir, &["update-ref", &remote_ref(branch), "HEAD"]); - let sha = git(workdir, &["rev-parse", "--short=12", "HEAD"])? - .trim() - .to_string(); - Ok(Validated { - sha, - files: st.files.len(), - branch: branch.to_string(), - }) + Ok(Applied::Conflict(reason)) + }; + for attempt in 0..2 { + if attempt > 0 { + if fetch(workdir, branch, Some(push_url)).is_err() { + return Err(err("GitHub injoignable, réessaie dans un instant.")); + } + } + if has_remote_ref(workdir, branch) + && count(workdir, &format!("HEAD..{}", remote_ref(branch))) > 0 + { + let r = remote_ref(branch); + if git( + workdir, + &[ + "-c", + &name, + "-c", + &email, + "rebase", + "--quiet", + "--autostash", + &r, + ], + ) + .is_err() + { + return undo( + "Le code a changé entre-temps au même endroit que cette correction. Elle ne peut pas être appliquée telle quelle.".into(), + ); + } + } + let dest = format!("HEAD:refs/heads/{branch}"); + match git(workdir, &["push", "--quiet", push_url, &dest]) { + Ok(_) => { + let _ = git(workdir, &["update-ref", &remote_ref(branch), "HEAD"]); + let sha = git(workdir, &["rev-parse", "HEAD"])?.trim().to_string(); + return Ok(Applied::Pushed(Validated { + sha, + files: st.files.len(), + branch: branch.to_string(), + })); + } + Err(e) => { + let m = redact(&e.0); + let race = m.contains("non-fast-forward") + || m.contains("rejected") + || m.contains("fetch first"); + if !race || attempt == 1 { + if committed { + let _ = git(workdir, &["reset", "--quiet", "--mixed", "HEAD~1"]); + } + return Err(err(format!("Envoi impossible : {m}"))); + } + } + } + } + unreachable!() } /* ---------------- Sauvegardes ---------------- */ diff --git a/apps/server/src/draft_origin.rs b/apps/server/src/draft_origin.rs index 1c98f19101..18ceb48dfc 100644 --- a/apps/server/src/draft_origin.rs +++ b/apps/server/src/draft_origin.rs @@ -65,6 +65,9 @@ fn token_after(content: &str, marker: &str) -> Option { /// Nature d'un run d'après son message déclencheur (+ déploiement visé pour une réparation). pub fn classify(content: &str) -> (&'static str, Option) { let c = content.trim_start(); + if c.starts_with(crate::correction::REDO_PREFIX) { + return ("repair", None); + } if c.contains("AUTO-RÉPARATION") { return ("repair", token_after(c, "Le déploiement ")); } @@ -130,7 +133,12 @@ pub fn touched_by_run( } /// Appelé en fin de run : attribue les fichiers touchés. Best effort, jamais bloquant pour le run. -pub async fn record_run(state: &AppState, run: &crate::agent_runs::RunRow, tools_json: &str) { +pub async fn record_run( + state: &AppState, + run: &crate::agent_runs::RunRow, + tools_json: &str, + reply: &str, +) { let Ok(Some(project)) = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE uuid = $1") .bind(&run.project_uuid) .fetch_optional(&state.pool) @@ -179,7 +187,7 @@ pub async fn record_run(state: &AppState, run: &crate::agent_runs::RunRow, tools } let (kind, dep) = classify(&run.content); let now = crate::state::now_str(); - for path in touched { + for path in touched.iter() { let _ = sqlx::query( r#"INSERT INTO draft_origins (project_uuid, path, run_uuid, agent_uuid, agent_name, kind, deployment_uuid, recorded_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) @@ -198,6 +206,8 @@ pub async fn record_run(state: &AppState, run: &crate::agent_runs::RunRow, tools .execute(&state.pool) .await; } + crate::correction::note_proposed(state, &run.project_uuid, kind, &agent_name, reply, &touched) + .await; } pub async fn load(pool: &PgPool, project_uuid: &str) -> HashMap { diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 1458e20c77..1ba0008ee7 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -14,6 +14,7 @@ mod backup_routes; mod cluster_routes; mod cluster_store; mod control_pg; +mod correction; mod cron_routes; mod db; mod deploy_queue; @@ -326,6 +327,13 @@ async fn main() -> Result<(), Box> { }); } + { + let state_c = state.clone(); + tokio::spawn(async move { + correction::run_loop(state_c).await; + }); + } + update_routes::spawn_auto_update(state.clone()); // Advertise agent tools on the local MCP server surface. @@ -358,6 +366,7 @@ async fn main() -> Result<(), Box> { .merge(actions_routes::router()) .merge(git_routes::router()) .merge(draft_routes::router()) + .merge(correction::router()) .merge(group_routes::router()) .merge(domain_catalog::router()) .merge(update_routes::router()) diff --git a/apps/server/src/mobile_routes.rs b/apps/server/src/mobile_routes.rs index 5c14b0f2c2..407533e551 100644 --- a/apps/server/src/mobile_routes.rs +++ b/apps/server/src/mobile_routes.rs @@ -204,6 +204,9 @@ async fn inbox( } } + // 4. Corrections : proposée, en ligne, échec. + events.extend(crate::correction::inbox_events(&state.pool, &projects, &since).await); + events.sort_by(|a, b| b.created_at.cmp(&a.created_at)); Ok(Json(json!({ "data": { diff --git a/apps/server/src/routes.rs b/apps/server/src/routes.rs index 9a7ee2d657..9b2bf28c00 100644 --- a/apps/server/src/routes.rs +++ b/apps/server/src/routes.rs @@ -1749,10 +1749,10 @@ fn build_repair_prompt(dep_uuid: &str, summary: &str, hint: &str, raw_logs: &str INTERDIT : répondre sans appeler d'outil. INTERDIT : write_project_file sur package-lock.json. INTERDIT : inventer une variable d'environnement.\n\ Appelle ces outils, dans cet ordre :\n\ 1. run_workdir_command avec command=\"npm install\"\n\ - 2. sync_workdir_to_github avec paths=[\"package-lock.json\"] et commit_message=\"fix: resynchroniser package-lock.json\"\n\ - 3. trigger_deploy, seulement si l'étape 2 a réussi.\n\ - La publication est verrouillée côté serveur : si un outil répond publish_approval_required, arrête-toi et demande la validation UNE fois (l'interface affiche le bouton). Ne relance pas npm install en boucle.\n\ - Ne dis pas que c'est corrigé avant le résultat de trigger_deploy." + Ne publie pas (pas de sync_workdir_to_github ni trigger_deploy) : DevForge teste la correction et l'utilisateur l'appliquera d'un bouton. Ne relance pas npm install en boucle.\n\ + Termine par ce rapport, en français simple :\n\ + Cassé : la liste des dépendances n'était plus à jour.\n\ + Changé : une phrase sur ce que tu as changé." ); } format!( @@ -1763,8 +1763,12 @@ fn build_repair_prompt(dep_uuid: &str, summary: &str, hint: &str, raw_logs: &str Logs :\n```\n{logs_tail}\n```\n\n\ N'écris pas que c'est fait tant que les outils n'ont pas répondu.\n\ 1. Identifie la cause dans les logs.\n\ - 2. Corrige avec l'outil adapté (run_workdir_command, write_project_file). Ne réécris pas un lockfile à la main.\n\ - 3. trigger_deploy, puis résume le résultat réel." + 2. Corrige EN LOCAL avec l'outil adapté (run_workdir_command, write_project_file). Ne réécris pas un lockfile à la main.\n\ + 3. Vérifie que le build passe (run_workdir_command ou run_application_tests).\n\ + 4. Ne publie pas : pas de sync_workdir_to_github, pas de trigger_deploy. DevForge teste ta correction et l'utilisateur l'appliquera d'un bouton.\n\ + 5. Termine par ce rapport, en français simple, sans jargon (pas de commit, branche, brouillon) :\n\ + Cassé : une ou deux phrases sur ce qui a cassé.\n\ + Changé : une phrase sur ce que tu as changé." ) } @@ -2400,15 +2404,60 @@ pub(crate) async fn handle_deploy_failure( text = crate::wake_causes::escalation_text(&cause, &summary) ), crate::wake_causes::WakeDecision::Normal => { + let logs: Option<(Option,)> = + sqlx::query_as("SELECT logs FROM deployments WHERE uuid = $1") + .bind(dep_uuid) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + let logs = logs.and_then(|(l,)| l).unwrap_or_default(); + if let Some(platform) = crate::correction::platform_failure(&summary, &logs) { + // Problème de la plateforme : on ne fait pas modifier l'app par un agent. + crate::correction::note_platform_failure( + &state.pool, + project_uuid, + dep_uuid, + platform, + ) + .await; + crate::wake_gate::record( + &state.pool, + project_uuid, + "deploy_fail", + &format!("deploy_fail:{}", cause.hash), + "Échec de déploiement", + crate::wake_gate::SKIPPED, + "problème de plateforme DevForge : pas de réparation de l'app", + &cause.hash, + None, + ) + .await; + crate::wake_causes::mark_woken(&state.pool, project_uuid, &cause.hash).await; + return Ok(()); + } if auto_repair { - let st = state.clone(); - let dep = dep_uuid.to_string(); - let hash = cause.hash.clone(); - tokio::spawn(async move { - if let Err(e) = auto_trigger_repair(&st, &dep, &hash).await { - tracing::warn!(error = %e, dep_uuid = %dep, "auto-réparation non lancée"); - } - }); + // Rustine répare seule (correction locale + test) ; pas de réveil du Coordinateur en parallèle. + let r = auto_trigger_repair(state, dep_uuid, &cause.hash).await; + if r.is_ok() { + crate::wake_gate::record( + &state.pool, + project_uuid, + "deploy_fail", + &format!("deploy_fail:{}", cause.hash), + "Échec de déploiement", + crate::wake_gate::LAUNCHED, + "réparation confiée à Rustine", + &cause.hash, + None, + ) + .await; + crate::wake_causes::mark_woken(&state.pool, project_uuid, &cause.hash).await; + return Ok(()); + } + if let Err(e) = r { + tracing::warn!(error = %e, dep_uuid, "auto-réparation non lancée"); + } } format!( "Événement projet : échec de déploiement.\n\ @@ -2459,7 +2508,7 @@ pub(crate) async fn wake_coordinator_deploy_fail( summary: &str, hint: &str, ) -> Result<(), String> { - handle_deploy_failure(state, project_uuid, dep_uuid, summary, hint, false).await + handle_deploy_failure(state, project_uuid, dep_uuid, summary, hint, true).await } /// Déploiement réussi : causes résolues, puis vérification HTTP de l'URL publique (P1-08). @@ -2525,7 +2574,7 @@ async fn post_deploy_smoke(state: &AppState, project_uuid: &str, dep_uuid: &str, .await; if let Some(summary) = verdict { tracing::warn!(project_uuid, dep_uuid, url, %summary, "vérification post-déploiement KO"); - let _ = handle_deploy_failure(state, project_uuid, dep_uuid, &summary, "", false).await; + let _ = handle_deploy_failure(state, project_uuid, dep_uuid, &summary, "", true).await; } else { let smoke = last .get("status") @@ -2643,17 +2692,7 @@ async fn auto_trigger_repair( .map_err(|e| e.to_string())? .ok_or_else(|| "Project not found".to_string())?; - let agent_uuid: Option<(String,)> = sqlx::query_as( - "SELECT uuid FROM project_agents WHERE project_uuid = $1 AND role = 'deploy' LIMIT 1", - ) - .bind(&project.uuid) - .fetch_optional(&state.pool) - .await - .map_err(|e| e.to_string())?; - - let Some((agent_uuid,)) = agent_uuid else { - return Err("No deploy agent found".to_string()); - }; + let agent_uuid = crate::correction::ensure_repair_agent(&state.pool, &project.uuid).await?; let summary = dep .error_summary @@ -2937,7 +2976,7 @@ async fn execute_claimed_run_inner( .map_err(|e| e.to_string())?; drop(handle); // Origine des fichiers du brouillon (badge + « Voir la discussion »). - crate::draft_origin::record_run(state, run, &tools_json).await; + crate::draft_origin::record_run(state, run, &tools_json, &result.reply).await; if status == "cancelled" { return Ok(()); } diff --git a/apps/server/src/wake_gate.rs b/apps/server/src/wake_gate.rs index 82596ddaae..4784ce1f47 100644 --- a/apps/server/src/wake_gate.rs +++ b/apps/server/src/wake_gate.rs @@ -324,24 +324,33 @@ pub async fn after_wake( .await; return; } - let run: Option<(String,)> = sqlx::query_as( - r#"SELECT uuid FROM agent_runs WHERE project_uuid = $1 AND agent_uuid = $2 - AND status IN ('pending', 'queued', 'running') - ORDER BY created_at DESC LIMIT 1"#, + // Le run peut déjà être terminé (tour court ou attendu par l'appelant) : on prend le run + // de l'agent créé depuis le réveil, quel que soit son statut, sinon le dernier en cours. + let since = gate.log_id.map(|id| id).unwrap_or(0); + let run: Option<(String, String)> = sqlx::query_as( + r#"SELECT r.uuid, r.status FROM agent_runs r + WHERE r.project_uuid = $1 AND r.agent_uuid = $2 + AND (r.status IN ('pending', 'queued', 'running') + OR r.created_at >= COALESCE((SELECT to_char(l.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS') FROM agent_wake_log l WHERE l.id = $3), '')) + ORDER BY r.created_at DESC LIMIT 1"#, ) .bind(project_uuid) .bind(agent_uuid) + .bind(since) .fetch_optional(pool) .await .ok() .flatten(); match (run, gate.log_id) { - (Some((run_uuid,)), Some(id)) => { + (Some((run_uuid, status)), Some(id)) => { let _ = sqlx::query("UPDATE agent_wake_log SET run_uuid = $1 WHERE id = $2") .bind(&run_uuid) .bind(id) .execute(pool) .await; + if !matches!(status.as_str(), "pending" | "queued" | "running") { + finish_by_run(pool, &run_uuid).await; + } } // Réveil dédupliqué par marqueur ou agent désactivé : rien ne tourne. _ => { diff --git a/apps/web/src/components/CorrectionsPanel.tsx b/apps/web/src/components/CorrectionsPanel.tsx new file mode 100644 index 0000000000..76ef2904b6 --- /dev/null +++ b/apps/web/src/components/CorrectionsPanel.tsx @@ -0,0 +1,304 @@ +import { useCallback, useEffect, useState } from 'preact/hooks'; +import { Check, ChevronDown, ChevronRight, CircleAlert, CircleDashed, FileText, Loader2, RotateCcw, Wrench, X } from 'lucide-preact'; +import { api, type Correction, type CorrectionsState, type CorrectionStep } from '../lib/api'; +import { cn } from '../lib/cn'; +import { InlinePatch } from './DiffViewer'; +import { Button, Modal, Spinner, useToast } from './ui'; + +/** Lien direct vers la correction d'une app (notifications, autres écrans). */ +export function correctionsHref(uuid: string): string { + return `/app/projects/view?uuid=${encodeURIComponent(uuid)}&tab=workspace&corrections=1`; +} + +export function useCorrections(uuid: string) { + const [data, setData] = useState(null); + const refresh = useCallback(async () => { + try { + setData(await api.corrections(uuid)); + } catch { + /* pas de dossier de travail : tuile neutre */ + } + }, [uuid]); + useEffect(() => { + void refresh(); + const t = window.setInterval(() => void refresh(), 15000); + return () => window.clearInterval(t); + }, [refresh]); + return { data, refresh }; +} + +const TONE: Record = { + testing: 'text-[var(--color-ink-muted)]', + proposed: 'text-[var(--color-warn)]', + conflict: 'text-[var(--color-danger)]', + deploying: 'text-[var(--color-accent)]', + live: 'text-[var(--color-ok)]', + failed: 'text-[var(--color-danger)]', +}; + +/** Statut d'une ligne pour la tuile : la correction en cours, sinon la dernière. */ +export function correctionsStatus(data: CorrectionsState | null): { text: string; tone: string } { + const c = data?.current ?? data?.history?.[0] ?? null; + if (!c) return { text: 'Rien en attente', tone: 'text-[var(--color-ink-muted)]' }; + const label = data?.current ? `${c.status_label} · ${c.persona}` : `Dernière : ${c.status_label.toLowerCase()}`; + return { text: label, tone: TONE[c.stage] ?? 'text-[var(--color-ink-muted)]' }; +} + +function StepIcon({ state }: { state: string }) { + if (state === 'done') return ; + if (state === 'failed') return ; + if (state === 'active') return ; + return ; +} + +export function Stepper({ steps }: { steps: CorrectionStep[] }) { + return ( +
    + {steps.map((s, i) => ( +
  1. +
    + + + + + +
    + {s.label} +
  2. + ))} +
+ ); +} + +function FileRow({ uuid, path, live }: { uuid: string; path: string; live: boolean }) { + const [open, setOpen] = useState(false); + const [patch, setPatch] = useState(null); + const [busy, setBusy] = useState(false); + async function toggle() { + const next = !open; + setOpen(next); + if (next && patch === null && live) { + setBusy(true); + try { + const r = await api.draftDiff(uuid, path); + setPatch(r.files?.[0]?.patch ?? ''); + } catch { + setPatch(''); + } finally { + setBusy(false); + } + } + } + return ( +
  • + + {open && ( +
    + {busy ?
    : patch ? :

    Pas de différence lisible (fichier binaire ou supprimé).

    } +
    + )} +
  • + ); +} + +function Report({ c, uuid, live }: { c: Correction; uuid: string; live: boolean }) { + const r = c.report; + const testTone = r.test_ok === true ? 'text-[var(--color-ok)]' : r.test_ok === false ? 'text-[var(--color-danger)]' : 'text-[var(--color-ink-muted)]'; + return ( +
    + {r.broke ? ( +
    +

    Ce qui a cassé

    +

    {r.broke}

    +
    + ) : null} +
    +

    Ce que j’ai changé

    +

    {r.changed}

    + {r.files.length > 0 && ( +
      + {r.files.map((f) => ( + + ))} +
    + )} +
    +
    +

    Test

    +

    {r.test}

    +
    +
    +

    Ce que tu dois faire

    +

    {r.todo}

    +
    +
    + ); +} + +type Props = { open: boolean; onClose: () => void; uuid: string; name: string; data: CorrectionsState | null; onChanged: () => Promise | void }; + +export function CorrectionsModal({ open, onClose, uuid, name, data, onChanged }: Props) { + const toast = useToast(); + const [confirm, setConfirm] = useState<'apply' | 'reject' | 'redo' | null>(null); + const [busy, setBusy] = useState(false); + const [conflict, setConflict] = useState(null); + const [details, setDetails] = useState(false); + const c = data?.current ?? null; + const conflictMsg = conflict ?? (c?.stage === 'conflict' ? c.note || 'Le code a changé au même endroit.' : null); + + useEffect(() => { + if (!open) { + setConfirm(null); + setConflict(null); + setDetails(false); + } + }, [open]); + + async function run() { + if (!confirm) return; + setBusy(true); + try { + if (confirm === 'apply') { + const r = await api.correctionApply(uuid); + if (r.conflict) { + setConflict(r.message ?? 'Conflit.'); + } else { + toast.push({ title: r.deployed ? 'Correction appliquée' : 'Correction envoyée', detail: r.deployed ? 'Mise en ligne en cours…' : 'Pas de nouvelle tentative automatique.', tone: 'info' }); + } + } else if (confirm === 'reject') { + await api.correctionReject(uuid); + toast.push({ title: 'Correction rejetée', detail: 'Récupérable pendant 7 jours.', tone: 'info' }); + } else { + await api.correctionRedo(uuid); + setConflict(null); + toast.push({ title: 'Rustine refait la correction', tone: 'info' }); + } + setConfirm(null); + await onChanged(); + } catch (e) { + toast.push({ title: 'Action impossible', detail: e instanceof Error ? e.message : String(e), tone: 'danger' }); + } finally { + setBusy(false); + } + } + + async function restore(h: Correction) { + if (!h.backup_id) return; + setBusy(true); + try { + await api.draftRestore(uuid, h.backup_id); + toast.push({ title: 'Correction remise', tone: 'info' }); + await onChanged(); + } catch (e) { + toast.push({ title: 'Restauration impossible', detail: e instanceof Error ? e.message : String(e), tone: 'danger' }); + } finally { + setBusy(false); + } + } + + const canAct = !!c && (c.stage === 'proposed' || c.stage === 'conflict' || c.stage === 'testing'); + const confirmText: Record = { + apply: `Appliquer cette correction et mettre ${name} en ligne ?`, + reject: 'Rejeter cette correction ? Elle reste récupérable 7 jours.', + redo: 'Mettre cette correction de côté et laisser Rustine la refaire sur le code à jour ?', + }; + + const footer = !c || !canAct ? undefined : confirm ? ( +
    +

    {confirmText[confirm]}

    +
    + + +
    +
    + ) : ( +
    + + {conflictMsg ? ( + + ) : ( + + )} +
    + ); + + return ( + !busy && onClose()} title="Corrections" description={name} size="md" footer={footer}> +
    + {c ? ( +
    +
    +

    + Correction proposée par {c.persona} + {c.status_label} +

    +

    {c.summary}

    +
    + + {conflictMsg && ( +
    + +

    {conflictMsg}

    +
    + )} + + {c.message ? ( +
    + + {details &&

    {c.message}

    } +
    + ) : null} +
    + ) : ( +

    Aucune correction en attente. Quand une mise en ligne échoue, Rustine prépare et teste une correction, puis te prévient.

    + )} + {data?.history?.length ? ( +
    +

    Historique

    +
      + {data.history.map((h) => ( +
    • + {h.status_label} + {h.summary} + {h.restorable && !c ? ( + + ) : null} +
    • + ))} +
    +
    + ) : null} +
    +
    + ); +} diff --git a/apps/web/src/components/DraftPanel.tsx b/apps/web/src/components/DraftPanel.tsx index 6f85287c3d..3763edd397 100644 --- a/apps/web/src/components/DraftPanel.tsx +++ b/apps/web/src/components/DraftPanel.tsx @@ -726,10 +726,10 @@ export function DraftsSection({ class: className }: { class?: string }) { }, []); if (!items.length) return null; return ( -
    +

    - Brouillons en attente · {items.length} + Corrections en attente · {items.length}

      {items.map((d) => ( diff --git a/apps/web/src/components/ProjectHome.tsx b/apps/web/src/components/ProjectHome.tsx index d046759662..b7f6d3e548 100644 --- a/apps/web/src/components/ProjectHome.tsx +++ b/apps/web/src/components/ProjectHome.tsx @@ -35,7 +35,7 @@ import { ProjectAgentsPanel, type ChatChip } from './ProjectAgentsPanel'; import { PersonaAvatar } from './personas/PersonaAvatar'; import { PreviewModal } from './workspace/PreviewModal'; import { StatusBadge } from './StatusBadge'; -import { DraftModal, DraftStatusLine, draftStatusText, filesLabel, useDraft } from './DraftPanel'; +import { CorrectionsModal, correctionsStatus, useCorrections } from './CorrectionsPanel'; import { PreviewPane } from './workspace/WorkspaceAtelier'; import type { PreviewServerStatus } from './workspace/WorkspaceTopBar'; import { Button, FadeIn, HubGrid, HubTile, Modal, Spinner, useToast } from './ui'; @@ -243,9 +243,10 @@ export function ProjectHome({ const [confirmOpen, setConfirmOpen] = useState(false); const [deployBusy, setDeployBusy] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); - const { draft, refresh: refreshDraft } = useDraft(uuid); + const { data: corrections, refresh: refreshCorrections } = useCorrections(uuid); + const pending = corrections?.current ?? null; const [draftOpen, setDraftOpen] = useState( - () => typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('draft') === '1', + () => typeof window !== 'undefined' && (new URLSearchParams(window.location.search).get('corrections') === '1' || new URLSearchParams(window.location.search).get('draft') === '1'), ); const latest = deployments[0] ?? null; @@ -380,7 +381,7 @@ export function ProjectHome({ key: 'health', label: '🩺 Est-ce que tout va bien ?', // Bilan en lecture seule : la puce ne doit jamais déclencher de modification. - send: 'Est-ce que tout va bien ? Fais juste un bilan rapide (app en ligne, dernière mise en ligne, brouillon), sans rien modifier.', + send: 'Est-ce que tout va bien ? Fais juste un bilan rapide (app en ligne, dernière mise en ligne, corrections en attente), sans rien modifier.', }, { key: 'design', @@ -525,11 +526,20 @@ export function ProjectHome({ name: PERSONAS.braise.name, title: braiseTitle(project?.name), tagline: PERSONAS.braise.tagline, - status: draftStatusText(draft) ? setDraftOpen(true)} /> : undefined, + status: pending ? ( + + ) : undefined, avatar: , smallAvatar: , placeholder: 'Dis à Braise ce que tu veux…', - emptyText: `Salut 👋 Je suis Braise. Dis-moi ce que tu veux changer dans ${name} : je prépare un plan, je construis en brouillon et je te montre l’aperçu. Rien ne part en ligne sans ton OK.`, + emptyText: `Salut 👋 Je suis Braise. Dis-moi ce que tu veux changer dans ${name} : je prépare un plan, je construis, je teste et je te montre l’aperçu. Rien ne part en ligne sans ton OK.`, chips, }} /> @@ -543,7 +553,7 @@ export function ProjectHome({
      {( [ - ['draft', 'Brouillon'], + ['draft', 'En test'], ['live', 'En ligne'], ] as const ).map(([key, label]) => ( @@ -710,11 +720,9 @@ export function ProjectHome({ ) : null}

      )} - {draft?.dirty ? ( + {pending ? (
      -

      - Le brouillon ({filesLabel(draft.count)}) n’est pas encore sur GitHub : il ne sera pas inclus. -

      +

      Une correction attend encore : elle ne sera pas incluse.

      - ) : ( -

      Les changements du brouillon qui ne sont pas encore sur GitHub ne seront pas inclus.

      - )} + ) : null}
      - { setDraftOpen(false); const u = new URL(window.location.href); - if (u.searchParams.has('draft')) { + if (u.searchParams.has('draft') || u.searchParams.has('corrections')) { u.searchParams.delete('draft'); + u.searchParams.delete('corrections'); window.history.replaceState(null, '', u.toString()); } }} uuid={uuid} name={name} - draft={draft} - onChanged={() => refreshDraft()} - onDeployNow={ - canDeploy - ? async () => { - await api.createDeployment(uuid, { git_message: 'Mise en ligne après validation du brouillon' }); - toast.push({ title: 'Mise en ligne lancée', detail: 'Reconstruction depuis GitHub…', tone: 'info' }); - const list = await api.deployments(uuid); - onDeployments(list.data ?? []); - } - : undefined - } + data={corrections} + onChanged={async () => { + await refreshCorrections(); + const list = await api.deployments(uuid); + onDeployments(list.data ?? []); + }} /> void) { @@ -47,7 +47,7 @@ export function ProjectWorkspace({ projectUuid, project, builderMode, builderAge const [draftOpen, setDraftOpen] = useState(initialQuery.draft); const [filesOpen, setFilesOpen] = useState(false); const [previewInfoOpen, setPreviewInfoOpen] = useState(false); - const { draft, refresh: refreshDraft } = useDraft(projectUuid); + const { data: corrections, refresh: refreshCorrections } = useCorrections(projectUuid); const [previewOpen, setPreviewOpen] = useState(false); const [mobilePane, setMobilePane] = useState<'chat' | 'atelier'>('chat'); const [atelierTab, setAtelierTab] = useState<'preview' | 'files'>('preview'); @@ -207,45 +207,36 @@ export function ProjectWorkspace({ projectUuid, project, builderMode, builderAge } const draftModal = ( - { setDraftOpen(false); - setQuery((u) => u.searchParams.delete('draft')); + setQuery((u) => u.searchParams.delete('corrections')); }} uuid={projectUuid} name={project?.name ?? 'cette app'} - draft={draft} - onChanged={() => refreshDraft(true)} + data={corrections} + onChanged={async () => { + await refreshCorrections(); + }} /> ); if (view === 'hub') { const running = previewStatus === 'running'; - const draftSubtitle = draft?.dirty ? ( -
      - - {filesLabel(draft.count)} - {relativeFr(draft.updated_at) ? ` · ${relativeFr(draft.updated_at)}` : ''} - - -
      - ) : ( -
      - {draft?.backups?.length ? 'Vide · sauvegarde restaurable' : 'Rien en attente'} -
      - ); + const cst = correctionsStatus(corrections); + const draftSubtitle =
      {cst.text}
      ; return ( } + title="Corrections" + icon={} subtitle={draftSubtitle} onClick={() => { setDraftOpen(true); - setQuery((u) => u.searchParams.set('draft', '1')); + setQuery((u) => u.searchParams.set('corrections', '1')); }} /> request<{ ok: boolean; drafts: DraftSummary[] }>(`/drafts`), + corrections: (uuid: string) => request(`/projects/${encodeURIComponent(uuid)}/corrections`), + correctionApply: (uuid: string) => + request<{ ok: boolean; deployed?: boolean; stage?: string; conflict?: boolean; message?: string; can_redo?: boolean }>( + `/projects/${encodeURIComponent(uuid)}/corrections/apply`, + { method: 'POST', body: JSON.stringify({ confirm: true }) }, + ), + correctionReject: (uuid: string) => + request<{ ok: boolean; backup_id: string }>(`/projects/${encodeURIComponent(uuid)}/corrections/reject`, { + method: 'POST', + body: JSON.stringify({ confirm: true }), + }), + correctionRedo: (uuid: string) => + request<{ ok: boolean }>(`/projects/${encodeURIComponent(uuid)}/corrections/redo`, { + method: 'POST', + body: JSON.stringify({ confirm: true }), + }), projectGitDiscard: (uuid: string) => request<{ ok: boolean; message?: string; output?: string }>( diff --git a/apps/web/src/lib/personas.ts b/apps/web/src/lib/personas.ts index 08e1c0a74d..c70fba82f3 100644 --- a/apps/web/src/lib/personas.ts +++ b/apps/web/src/lib/personas.ts @@ -74,6 +74,7 @@ export function personaForAgent(agent: ProjectAgent): PersonaKey | null { if (agent.role === 'coordinator') return 'braise'; if (agent.kind === 'subagent' || agent.role === 'worker') return 'braise'; if (agent.role === 'reviewer') return 'plume'; + if (agent.role === 'repair') return 'rustine'; if (PHARE_ROLES.has(agent.role)) return 'phare'; const isRoutine = agent.trigger_type === 'cron' || agent.trigger_type === 'event'; if (isRoutine) { diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index e5f678379e..80182af237 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -1420,6 +1420,7 @@ fn system_prompt(ctx: &AgentChatContext, latest: &str) -> String { }; format!("Tu es l'agent Déploiements. Priorité : statut des builds, versions en ligne, logs (get_project, get_deployment_logs, http_smoke). Ne lance un déploiement et ne modifie le code que si on te le demande clairement.\n{template_nudge}Conserve les routes Pocket ID du template (`/api/auth/login`, `/api/auth/callback/pocket-id`) : les comptes Pocket ID s'en servent pour entrer dans l'app.\n{local}") } + "repair" => format!("Tu es Rustine, l'agent de réparation. Une mise en ligne a échoué : tu trouves la cause et tu la corriges dans le dossier de travail.\nMarche à suivre : 1) diagnostique (get_deployment_logs, read_project_file, run_workdir_command en lecture : git status/diff/log, cat, grep) ; 2) corrige en local (write_project_file, run_workdir_command) ; 3) teste (run_application_tests, build via run_workdir_command, start_local_preview) ; 4) ne publie jamais toi-même : DevForge teste ta correction puis l'utilisateur l'applique d'un bouton. N'utilise pas ask_user avant que les tests passent.\nSi la cause vient de la plateforme (délai Docker, conteneur qui ne démarre pas sans erreur de l'app), ne modifie pas l'app : dis-le simplement.\nTermine toujours par ce rapport, en français simple, sans jargon (pas de commit, branche ni brouillon) :\nCassé : une ou deux phrases.\nChangé : une phrase.\n{local}"), "runner" => format!("Tu es l'agent Runners. Tu surveilles les runners GitHub liés au dépôt (en ligne, occupés, en erreur). Rapporte les faits avec get_project et les outils GitHub disponibles. Ne modifie pas le code : le Workspace sert à ça. Si tu ne peux pas lire les runners, dis-le et oriente vers la page Runners.\n{local}"), "actions" => format!("Tu es l'agent Actions. Tu surveilles les workflows GitHub Actions du dépôt (succès, échec, en cours). Rapporte le dernier run utile. Ne réécris pas l'application : le Workspace sert à ça.\n{local}"), "crons" => format!("Tu es l'agent Crons. Tu surveilles les tâches planifiées du projet (actives, dernière exécution, échecs).\n{local}"), diff --git a/crates/agent/src/roles.rs b/crates/agent/src/roles.rs index f08fd96935..daa235dcad 100644 --- a/crates/agent/src/roles.rs +++ b/crates/agent/src/roles.rs @@ -100,6 +100,7 @@ const DEPLOY: &[&str] = &[ "run_workdir_command", "start_local_preview", "local_preview_status", + "run_application_tests", "propose_plan", // auto-réparation : pousse le correctif (lockfile) après validation "write_project_file", @@ -109,6 +110,25 @@ const DEPLOY: &[&str] = &[ "create_github_repo", ]; +/// Rustine (réparation) : diagnostique, corrige dans le dossier de travail, teste. Ne publie pas : +/// l'utilisateur applique la correction d'un bouton (« Appliquer »). +const REPAIR: &[&str] = &[ + "get_project", + "list_deployments", + "get_deployment_logs", + "list_env_vars", + "list_project_files", + "read_project_file", + "write_project_file", + "run_workdir_command", + "run_application_tests", + "start_local_preview", + "stop_local_preview", + "local_preview_status", + "http_smoke", + "read_github_file", +]; + const OPS: &[&str] = &[ "create_routine", "list_routines", @@ -149,6 +169,7 @@ pub fn tools_for_role(role: &str, kind: Option<&str>) -> Option<&'static [&'stat "coordinator" => None, "custom" | "worker" | "subagent" => Some(WORKER), "deploy" => Some(DEPLOY), + "repair" => Some(REPAIR), "ops" => Some(OPS), "reviewer" => Some(REVIEWER), "runner" | "actions" | "crons" => Some(WATCHER), diff --git a/crates/agent/src/tools/preview.rs b/crates/agent/src/tools/preview.rs index ce8a2f8143..073b90c5ac 100644 --- a/crates/agent/src/tools/preview.rs +++ b/crates/agent/src/tools/preview.rs @@ -146,6 +146,13 @@ impl Tool for StartLocalPreviewTool { } let project_env = load_project_env_vars(self.pool.as_ref(), &ctx.uuid).await; + // Secrets de prod non montés en aperçu : on donne leurs NOMS (jamais les valeurs) pour que + // l'agent n'accuse pas une variable « manquante » qui existe bien en production. + let withheld: Vec = project_env + .iter() + .filter(|v| v.secret && !v.preview_allowed) + .map(|v| v.key.clone()) + .collect(); // Preview atelier : pas de secrets de prod (sauf opt-in preview_allowed). let project_env = devforge_env::env_for_preview(&project_env); let _ = devforge_env::materialize_dotenv_file(workdir_path, &project_env); @@ -317,6 +324,8 @@ impl Tool for StartLocalPreviewTool { "npm_install": npm_install, "env_count": env_keys.len(), "env_keys": env_keys, + "withheld_secret_keys": withheld, + "env_note": "Les variables de withheld_secret_keys existent sur le projet (production) mais ne sont pas montées en aperçu. Une erreur qui les concerne en aperçu n'est pas la cause d'un échec de mise en ligne.", "public_ok": true, "public_status": public_status, "logs_tail": logs_tail, diff --git a/crates/agent/src/tools/workdir_cmd.rs b/crates/agent/src/tools/workdir_cmd.rs index 7fc4ef1988..932065676a 100644 --- a/crates/agent/src/tools/workdir_cmd.rs +++ b/crates/agent/src/tools/workdir_cmd.rs @@ -13,8 +13,59 @@ pub struct RunWorkdirCommandTool { const ALLOWED_BINS: &[&str] = &[ "npm", "pnpm", "yarn", "bun", "node", "npx", "astro", "tsc", "vite", "eslint", "prettier", + "git", "cat", "grep", ]; +/// Sous-commandes git en lecture seule (diagnostic). +const GIT_READ_ONLY: &[&str] = &[ + "status", + "diff", + "log", + "show", + "blame", + "ls-files", + "rev-parse", + "branch", + "grep", +]; + +/// Lecture seule pour le diagnostic : git (sous-commandes sûres), cat, grep. Jamais de fichiers secrets. +fn validate_read_only(bin: &str, parts: &[String]) -> std::result::Result<(), String> { + for a in &parts[1..] { + let l = a.to_ascii_lowercase(); + if a.starts_with('/') + || a.starts_with('~') + || l.contains(".env") + || l.contains("secret") + || l.contains(".git/") + { + return Err(format!("argument interdit en lecture : {a}")); + } + } + if bin == "git" { + let sub = parts.get(1).map(|s| s.as_str()).unwrap_or(""); + if !GIT_READ_ONLY.contains(&sub) { + return Err(format!( + "git : seules les sous-commandes en lecture sont permises ({})", + GIT_READ_ONLY.join(", ") + )); + } + if parts.iter().any(|p| { + p == "-c" + || p.starts_with("--output") + || p == "-D" + || p == "-d" + || p == "-m" + || p == "-M" + || p.starts_with("--set") + || p.starts_with("--delete") + }) { + return Err("git : option d'écriture interdite".into()); + } + } + Ok(()) +} + const BLOCKED_TOKENS: &[&str] = &[ ";", "&&", @@ -70,6 +121,9 @@ fn validate_command(raw: &str) -> std::result::Result, String> { ALLOWED_BINS.join(", ") )); } + if matches!(bin_base, "git" | "cat" | "grep") { + validate_read_only(bin_base, &parts)?; + } if matches!(bin_base, "npm" | "pnpm" | "yarn" | "bun") { let joined = parts.join(" ").to_ascii_lowercase(); if joined.contains("publish") || joined.contains("login") || joined.contains("adduser") { @@ -264,6 +318,19 @@ impl Tool for RunWorkdirCommandTool { mod tests { use super::*; + #[test] + fn read_only_diagnosis() { + assert!(validate_command("git status").is_ok()); + assert!(validate_command("git log --oneline -5").is_ok()); + assert!(validate_command("cat src/main.ts").is_ok()); + assert!(validate_command("grep -rn TURSO src").is_ok()); + assert!(validate_command("git push").is_err()); + assert!(validate_command("git -c x=y status").is_err()); + assert!(validate_command("cat .env").is_err()); + assert!(validate_command("cat /etc/passwd").is_err()); + assert!(validate_command("git branch -D main").is_err()); + } + #[test] fn allows_npm_run_build() { let p = validate_command("npm run build").unwrap(); diff --git a/crates/deploy/src/lib.rs b/crates/deploy/src/lib.rs index cdeebc4b0f..f033e6068d 100644 --- a/crates/deploy/src/lib.rs +++ b/crates/deploy/src/lib.rs @@ -1600,7 +1600,7 @@ if (-not $candidates) { Write-Error 'docker missing'; exit 1 } } Err(e) => { logs.push_str(&format!( - "[blue-green] ⚠️ Erreur application labels: {}\n", + "[blue-green] ⚠️ Erreur application labels (docker_update_labels, plateforme): {}\n", e )); } @@ -1877,8 +1877,12 @@ if (-not $candidates) { Write-Error 'docker missing'; exit 1 } } if std::time::Instant::now() >= deadline { logs.push_str(&format!( - "[readiness] ❌ {container} ne répond pas en HTTP après {timeout}s (dernier état : {last}) — l’ancien conteneur reste en production\n" + "[readiness] ❌ {container} ne répond pas en HTTP après {timeout}s (dernier état : {last}) — l’ancien conteneur reste en production (wait_container_ready timeout)\n" )); + let tail = format!("docker logs --tail 40 {container} 2>&1"); + if let Ok(r) = self.executor.exec(server, workdir, &tail, 15).await { + logs.push_str(&format!("[readiness] logs: {}\n", trim_out(&r.output))); + } return false; } tokio::time::sleep(std::time::Duration::from_millis(1000)).await; diff --git a/package-lock.json b/package-lock.json index 365b9153f7..52df085f3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ }, "apps/web": { "name": "apps-web", - "version": "2.0.224", + "version": "2.0.235", "dependencies": { "@astrojs/check": "^0.9.10", "@astrojs/preact": "^6.0.5", From ffd694856d37f12fda45f0613353da7e3ae57ddf Mon Sep 17 00:00:00 2001 From: Mathieu JESER Date: Tue, 13 Oct 2026 23:15:16 +0200 Subject: [PATCH 2/2] chore: revert lockfile --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 52df085f3e..365b9153f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ }, "apps/web": { "name": "apps-web", - "version": "2.0.235", + "version": "2.0.224", "dependencies": { "@astrojs/check": "^0.9.10", "@astrojs/preact": "^6.0.5",