From 82b9b23699d629476b6f27cdc441a18e67d304b7 Mon Sep 17 00:00:00 2001 From: bobdivx Date: Fri, 30 Oct 2026 14:35:29 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20mod=C3=A8les=20d'agent,=20carte=20live,?= =?UTF-8?q?=20journal=20=C2=AB=20montre-moi=20ton=20travail=20=C2=BB=20et?= =?UTF-8?q?=20r=C3=A9veil=20du=20bot=20Grok?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bibliothèque de 5 modèles (/deploy-verify, /figure-it-out, /watch-page, /swarm, /reflect) : 3 étapes fixes, une règle, lancement en un bouton - Carte live par run (texte d'état, étapes/workers, pastilles) — site et Android - Outil record_decision + journal (quoi, pourquoi, preuve lien/chemin, issue) fusionné avec les reçus - Réglage Bot Grok (URL + clé chiffrées, jamais renvoyées), bouton Réveiller, garde SSRF de page-watch - Arène : limite documentée (pas de choix de modèle par worker) --- .../java/app/jeser/devforge/data/ApiClient.kt | 26 + .../java/app/jeser/devforge/data/Playbooks.kt | 126 +++ .../java/app/jeser/devforge/ui/AppRoot.kt | 4 + .../devforge/ui/project/PlaybooksSheet.kt | 260 ++++++ .../devforge/ui/project/ProjectScreen.kt | 24 +- .../devforge/ui/project/ProjectViewModel.kt | 67 ++ .../java/app/jeser/devforge/PlaybooksTest.kt | 65 ++ apps/server/src/db.rs | 2 + apps/server/src/grok_bot.rs | 468 +++++++++++ apps/server/src/grok_bot_tests.rs | 195 +++++ apps/server/src/main.rs | 8 + apps/server/src/page_watch.rs | 19 +- apps/server/src/playbooks.rs | 745 ++++++++++++++++++ apps/server/src/playbooks_tests.rs | 264 +++++++ apps/server/src/run_routes.rs | 6 +- apps/web/src/components/ActiveRuns.tsx | 17 +- apps/web/src/components/GrokBotTile.tsx | 185 +++++ apps/web/src/components/PlaybooksTile.tsx | 166 ++++ apps/web/src/components/ProjectAgentsHub.tsx | 2 + apps/web/src/components/RunLiveCard.test.tsx | 47 ++ apps/web/src/components/RunLiveCard.tsx | 226 ++++++ apps/web/src/components/SettingsPage.tsx | 2 + apps/web/src/components/WakeGrokButton.tsx | 56 ++ .../components/workspace/DeploymentsSheet.tsx | 2 + apps/web/src/lib/api.ts | 90 +++ apps/web/src/lib/run-live.test.ts | 43 + apps/web/src/lib/run-live.ts | 102 +++ apps/web/src/styles/global.css | 20 + crates/agent/src/events.rs | 8 + crates/agent/src/lib.rs | 6 + crates/agent/src/roles.rs | 3 + crates/agent/src/tools/decision.rs | 182 +++++ crates/agent/src/tools/mod.rs | 2 + docs/plans/agent-playbooks.md | 90 +++ 34 files changed, 3520 insertions(+), 8 deletions(-) create mode 100644 apps/android/app/src/main/java/app/jeser/devforge/data/Playbooks.kt create mode 100644 apps/android/app/src/main/java/app/jeser/devforge/ui/project/PlaybooksSheet.kt create mode 100644 apps/android/app/src/test/java/app/jeser/devforge/PlaybooksTest.kt create mode 100644 apps/server/src/grok_bot.rs create mode 100644 apps/server/src/grok_bot_tests.rs create mode 100644 apps/server/src/playbooks.rs create mode 100644 apps/server/src/playbooks_tests.rs create mode 100644 apps/web/src/components/GrokBotTile.tsx create mode 100644 apps/web/src/components/PlaybooksTile.tsx create mode 100644 apps/web/src/components/RunLiveCard.test.tsx create mode 100644 apps/web/src/components/RunLiveCard.tsx create mode 100644 apps/web/src/components/WakeGrokButton.tsx create mode 100644 apps/web/src/lib/run-live.test.ts create mode 100644 apps/web/src/lib/run-live.ts create mode 100644 crates/agent/src/tools/decision.rs create mode 100644 docs/plans/agent-playbooks.md 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 fa9d4583d9..cba00e5a58 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 @@ -405,6 +405,32 @@ class ApiClient( suspend fun agentRun(runUuid: String): AgentRunInfo = get("/agent/runs/${enc(runUuid)}", DataEnvelope.serializer(AgentRunInfo.serializer())).data + /* Modèles d'agent : catalogue, lancement en un bouton, carte live, réveil du bot Grok. */ + suspend fun playbooks(): List = + get("/playbooks", DataEnvelope.serializer(ListSerializer(Playbook.serializer()))).data + + suspend fun runPlaybook(projectUuid: String, playbookId: String, goal: String?): PlaybookStarted = + send( + "POST", + "/projects/${enc(projectUuid)}/playbooks/${enc(playbookId)}/run", + buildJsonObject { goal?.trim()?.takeIf { it.isNotEmpty() }?.let { put("goal", it) } }, + DataEnvelope.serializer(PlaybookStarted.serializer()), + ).data + + suspend fun runLive(runUuid: String): RunLive = + get("/agent/runs/${enc(runUuid)}/live", DataEnvelope.serializer(RunLive.serializer())).data + + suspend fun wakeGrokBot(projectUuid: String, runUuid: String?): GrokWakeResult = + send( + "POST", + "/grok-bot/wake", + buildJsonObject { + put("project_uuid", projectUuid) + runUuid?.let { put("run_uuid", it) } + }, + DataEnvelope.serializer(GrokWakeResult.serializer()), + ).data + /** Bail SQL : `pause` | `resume` | `retry`. Refus (409) → exception avec le message serveur. */ suspend fun controlRun(runUuid: String, action: String): RunCancel = send("POST", "/agent/runs/${enc(runUuid)}/${enc(action)}", buildJsonObject { }, DataEnvelope.serializer(RunCancel.serializer())).data diff --git a/apps/android/app/src/main/java/app/jeser/devforge/data/Playbooks.kt b/apps/android/app/src/main/java/app/jeser/devforge/data/Playbooks.kt new file mode 100644 index 0000000000..e5eac38d1c --- /dev/null +++ b/apps/android/app/src/main/java/app/jeser/devforge/data/Playbooks.kt @@ -0,0 +1,126 @@ +package app.jeser.devforge.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/* ---------------- Modèles d'agent (playbooks) et carte live d'un run ---------------- */ + +@Serializable +data class Playbook( + val id: String, + val slash: String = "", + val name: String = "", + val description: String = "", + val steps: List = emptyList(), + val rule: String = "", + val visual: String = "", + @SerialName("goal_label") val goalLabel: String = "", + @SerialName("goal_placeholder") val goalPlaceholder: String = "", + @SerialName("uses_workers") val usesWorkers: Boolean = false, +) + +@Serializable +data class PlaybookStarted( + @SerialName("run_uuid") val runUuid: String, + @SerialName("agent_uuid") val agentUuid: String? = null, + val playbook: String? = null, + val title: String? = null, +) + +@Serializable +data class PlaybookRef(val id: String = "", val slash: String = "", val name: String = "", val rule: String = "", val visual: String = "") + +@Serializable +data class LiveStep(val n: Int = 0, val label: String = "", val state: String = "pending") + +@Serializable +data class LiveWorker( + @SerialName("run_uuid") val runUuid: String, + val title: String? = null, + @SerialName("agent_name") val agentName: String? = null, + val status: String = "queued", + val live: Boolean = false, + val summary: String? = null, + val error: String? = null, +) + +@Serializable +data class LiveCounters( + @SerialName("tools_ok") val toolsOk: Int = 0, + @SerialName("tools_failed") val toolsFailed: Int = 0, + @SerialName("workers_total") val workersTotal: Int = 0, + @SerialName("workers_done") val workersDone: Int = 0, + @SerialName("workers_failed") val workersFailed: Int = 0, + val decisions: Int = 0, +) + +/** Une ligne « Montre-moi ton travail » : quoi, pourquoi, preuve (lien ou chemin), issue. */ +@Serializable +data class RunDecision( + val what: String = "", + val why: String = "", + val evidence: String = "", + val outcome: String = "unknown", + val step: Int? = null, +) + +@Serializable +data class RunLive( + @SerialName("run_uuid") val runUuid: String, + @SerialName("project_uuid") val projectUuid: String = "", + val status: String = "running", + val live: Boolean = false, + val verdict: String = "running", + @SerialName("status_text") val statusText: String = "", + val title: String? = null, + val error: String? = null, + val playbook: PlaybookRef? = null, + val steps: List = emptyList(), + val workers: List = emptyList(), + val counters: LiveCounters = LiveCounters(), + val decisions: List = emptyList(), +) + +@Serializable +data class GrokWakeResult(val ok: Boolean = false, val status: Int? = null, val message: String = "", val event: String = "") + +/** Libellé court d'une étape (même vocabulaire que le site). */ +fun stepStateLabel(state: String): String = when (state) { + "ok" -> "OK" + "failed" -> "Échec" + "running" -> "En cours" + "missing" -> "Sans résultat" + "skipped" -> "Ignorée" + else -> "À venir" +} + +/** État d'un worker ramené au vocabulaire des étapes. */ +fun workerStepState(status: String): String = when (status) { + "completed" -> "ok" + "failed", "cancelled" -> "failed" + "running" -> "running" + else -> "pending" +} + +fun verdictLabel(v: String): String = when (v) { + "ok" -> "Réussi" + "failed" -> "Échec" + "incomplete" -> "Incomplet" + else -> "En cours" +} + +/** Pastilles compteurs : seules les valeurs utiles. */ +fun livePills(c: LiveCounters): List = buildList { + if (c.workersTotal > 0) add("Workers ${c.workersDone}/${c.workersTotal}") + add("${c.toolsOk} action${if (c.toolsOk > 1) "s" else ""} OK") + if (c.toolsFailed > 0) add("${c.toolsFailed} échec${if (c.toolsFailed > 1) "s" else ""}") + add("${c.decisions} décision${if (c.decisions > 1) "s" else ""}") +} + +/** Preuve ouvrable dans le navigateur : http(s) uniquement. Un chemin reste du texte. */ +fun evidenceUrl(evidence: String): String? { + val e = evidence.trim() + return if (Regex("^https?://[^\\s/]", RegexOption.IGNORE_CASE).containsMatchIn(e) && e.none { it.isWhitespace() }) e else null +} + +val RunLive.needsHelp: Boolean get() = verdict == "failed" || verdict == "incomplete" 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 e09ecf5f46..38060d429b 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 @@ -397,6 +397,10 @@ private fun ProjectRoute(graph: AppGraph, uuid: String, onBack: (() -> Unit)?, o onUrlOpened = vm::urlOpened, onOpenDraft = vm::openDraft, onOpenCorrections = vm::openCorrections, + onOpenPlaybooks = vm::openPlaybooks, + onClosePlaybooks = vm::closePlaybooks, + onRunPlaybook = vm::runPlaybook, + onWakeGrok = vm::wakeGrok, onApplyCorrection = vm::applyCorrection, onRejectCorrection = vm::rejectCorrection, onRedoCorrection = vm::redoCorrection, diff --git a/apps/android/app/src/main/java/app/jeser/devforge/ui/project/PlaybooksSheet.kt b/apps/android/app/src/main/java/app/jeser/devforge/ui/project/PlaybooksSheet.kt new file mode 100644 index 0000000000..5ff2172b9a --- /dev/null +++ b/apps/android/app/src/main/java/app/jeser/devforge/ui/project/PlaybooksSheet.kt @@ -0,0 +1,260 @@ +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.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +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.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.jeser.devforge.data.Playbook +import app.jeser.devforge.data.RunLive +import app.jeser.devforge.data.evidenceUrl +import app.jeser.devforge.data.livePills +import app.jeser.devforge.data.needsHelp +import app.jeser.devforge.data.stepStateLabel +import app.jeser.devforge.data.verdictLabel +import app.jeser.devforge.data.workerStepState +import app.jeser.devforge.ui.theme.DfColors + +fun playbookVerdictTone(v: String): Color = when (v) { + "ok" -> DfColors.Ok + "failed" -> DfColors.Danger + "incomplete" -> DfColors.Warn + else -> DfColors.Accent +} + +fun stepTone(state: String): Color = when (state) { + "ok" -> DfColors.Ok + "failed" -> DfColors.Danger + "missing" -> DfColors.Warn + "running" -> DfColors.Accent + else -> DfColors.InkFaint +} + +/** Feuille « Modèles » : carte live du dernier lancement, puis un bouton « Lancer » par modèle. */ +@Composable +fun PlaybooksSheet( + state: ProjectUiState, + onDismiss: () -> Unit, + onRun: (String, String?) -> Unit, + onWake: () -> Unit, +) { + DraftSheetFrame(onDismiss) { + Text("Modèles d'agent", style = MaterialTheme.typography.titleLarge, modifier = Modifier.testTag("playbooks-sheet")) + Text("Un bouton, trois étapes fixes, une règle.", color = DfColors.InkMuted, fontSize = 14.sp) + ErrorBox(state.playbookError) + state.playbookRun?.let { RunLiveCard(it, waking = state.grokWaking, wake = state.grokWake, onWake = onWake) } + val list = state.playbooks + if (list == null) { + if (state.playbookError == null) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp, color = DfColors.Accent) + Text("Chargement…", color = DfColors.InkMuted) + } + } + } else { + list.forEach { p -> PlaybookCard(p, busy = state.playbookBusy, onRun = onRun) } + } + } +} + +@Composable +private fun PlaybookCard(p: Playbook, busy: String?, onRun: (String, String?) -> Unit) { + var goal by rememberSaveable(p.id) { mutableStateOf("") } + Surface( + color = DfColors.Surface, + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, DfColors.Line), + modifier = Modifier.fillMaxWidth().testTag("playbook-${p.id}"), + ) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(p.slash, color = DfColors.Accent, fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text(p.name, fontWeight = FontWeight.SemiBold, fontSize = 16.sp) + Text(p.description, color = DfColors.InkMuted, fontSize = 13.sp) + p.steps.forEachIndexed { i, s -> + Row(verticalAlignment = Alignment.Top) { + Box(Modifier.size(20.dp).background(Color.White.copy(alpha = .06f), CircleShape), contentAlignment = Alignment.Center) { + Text("${i + 1}", fontSize = 11.sp, fontWeight = FontWeight.SemiBold) + } + Spacer(Modifier.width(8.dp)) + Text(s, fontSize = 13.sp, modifier = Modifier.weight(1f)) + } + } + Surface( + color = DfColors.Warn.copy(alpha = .06f), + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, DfColors.Warn.copy(alpha = .25f)), + ) { + Text("Règle : ${p.rule}", fontSize = 12.sp, color = DfColors.InkMuted, modifier = Modifier.fillMaxWidth().padding(10.dp)) + } + if (p.goalLabel.isNotBlank()) { + OutlinedTextField( + value = goal, + onValueChange = { goal = it.replace('\n', ' ').take(300) }, + label = { Text(p.goalLabel) }, + placeholder = { Text(p.goalPlaceholder, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = DfColors.Accent, + unfocusedBorderColor = DfColors.LineStrong, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + Button( + onClick = { onRun(p.id, goal.trim().ifBlank { null }) }, + enabled = busy == null, + colors = ButtonDefaults.buttonColors(containerColor = DfColors.Accent, contentColor = DfColors.OnAccent), + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).testTag("playbook-run-${p.id}"), + ) { + if (busy == p.id) CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = DfColors.OnAccent) + else Text("Lancer") + } + } + } +} + +/** Carte live : texte d'état, étapes, workers, pastilles, journal « Montre-moi ton travail », réveil du bot. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RunLiveCard( + live: RunLive, + waking: Boolean, + wake: app.jeser.devforge.data.GrokWakeResult?, + onWake: () -> Unit, +) { + var journal by rememberSaveable(live.runUuid) { mutableStateOf(false) } + val uri = LocalUriHandler.current + val tone = if (live.live) DfColors.Accent else playbookVerdictTone(live.verdict) + Surface( + color = DfColors.Surface, + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, tone.copy(alpha = .4f)), + modifier = Modifier.fillMaxWidth().testTag("run-live-card"), + ) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (live.live) CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 2.dp, color = tone) + else Box(Modifier.size(10.dp).background(tone, CircleShape)) + Spacer(Modifier.width(8.dp)) + Text( + (if (live.live) "ANALYSE EN DIRECT" else verdictLabel(live.verdict).uppercase()) + + (live.playbook?.slash?.let { " · $it" } ?: ""), + fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = DfColors.InkFaint, + ) + } + Text(live.statusText, fontWeight = FontWeight.Medium, fontSize = 15.sp, modifier = Modifier.testTag("run-live-status")) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + livePills(live.counters).forEach { label -> + Surface(color = Color.White.copy(alpha = .04f), shape = RoundedCornerShape(50), border = BorderStroke(1.dp, DfColors.Line)) { + Text(label, fontSize = 11.sp, color = DfColors.InkMuted, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp)) + } + } + } + live.steps.forEach { s -> StateRow(s.label, s.state) } + live.workers.forEach { w -> StateRow(w.title ?: w.agentName ?: "Worker", workerStepState(w.status)) } + live.playbook?.rule?.takeIf { it.isNotBlank() }?.let { + Text("Règle : $it", fontSize = 12.sp, color = DfColors.InkMuted) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedButton(onClick = { journal = !journal }, modifier = Modifier.heightIn(min = 48.dp).weight(1f)) { + Text("Mon travail (${live.decisions.size})", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + val help = live.needsHelp + Button( + onClick = onWake, + enabled = !waking, + colors = if (help) ButtonDefaults.buttonColors(containerColor = DfColors.Accent, contentColor = DfColors.OnAccent) + else ButtonDefaults.buttonColors(containerColor = DfColors.Surface2, contentColor = DfColors.Ink), + modifier = Modifier.heightIn(min = 48.dp).weight(1f).testTag("wake-grok"), + ) { + if (waking) CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = DfColors.InkMuted) + else Text("Réveiller Grok", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + wake?.let { + Text(it.message, fontSize = 13.sp, color = if (it.ok) DfColors.Ok else DfColors.Danger, modifier = Modifier.testTag("wake-result")) + } + if (journal) { + if (live.decisions.isEmpty()) { + Text("Aucune décision notée pour l'instant.", fontSize = 13.sp, color = DfColors.InkMuted) + } + live.decisions.forEach { d -> + Column( + Modifier.fillMaxWidth().background(Color.White.copy(alpha = .02f), RoundedCornerShape(12.dp)).padding(10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + (d.step?.let { "Étape $it · " } ?: "") + d.what, + fontWeight = FontWeight.SemiBold, fontSize = 13.sp, + ) + Text(d.why, fontSize = 12.sp, color = DfColors.InkMuted) + val url = evidenceUrl(d.evidence) + Text( + d.evidence, + fontFamily = FontFamily.Monospace, + fontSize = 11.5.sp, + color = if (url != null) DfColors.Accent else DfColors.Ink, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = if (url != null) Modifier.heightIn(min = 32.dp).clickable { runCatching { uri.openUri(url) } } else Modifier, + ) + Text(stepStateLabel(if (d.outcome == "unknown") "failed" else d.outcome), fontSize = 11.sp, color = stepTone(d.outcome)) + } + } + } + } + } +} + +@Composable +private fun StateRow(label: String, state: String) { + val c = stepTone(state) + Row( + Modifier.fillMaxWidth().heightIn(min = 40.dp) + .background(if (state == "running") DfColors.Accent.copy(alpha = .06f) else Color.Transparent, RoundedCornerShape(10.dp)) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (state == "running") CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp, color = c) + else Box(Modifier.size(10.dp).background(c, CircleShape)) + Spacer(Modifier.width(10.dp)) + Text(label, fontSize = 13.sp, modifier = Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(stepStateLabel(state), fontSize = 11.sp, color = c) + } +} 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 d32b6dc140..7c99f73422 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.AutoAwesome import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.History @@ -71,6 +72,7 @@ import app.jeser.devforge.data.ContainerState import app.jeser.devforge.data.DeployPlan import app.jeser.devforge.data.Deployment import app.jeser.devforge.data.SpecFeature +import app.jeser.devforge.data.needsHelp import app.jeser.devforge.ui.apps.hostOf import app.jeser.devforge.ui.components.AppIcon import app.jeser.devforge.ui.components.ConfirmActionSheet @@ -125,10 +127,15 @@ data class ProjectActions( val onAttachImage: (ByteArray, String, String) -> Unit = { _, _, _ -> }, val onRemoveImage: (String) -> Unit = {}, val attachmentUrl: (String) -> String? = { null }, + /* Modèles d'agent : un bouton par modèle, carte live, réveil du bot Grok. */ + val onOpenPlaybooks: () -> Unit = {}, + val onClosePlaybooks: () -> Unit = {}, + val onRunPlaybook: (String, String?) -> Unit = { _, _ -> }, + val onWakeGrok: () -> Unit = {}, ) /** 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, Corrections } +enum class ProjectSheet { None, Details, Deploys, Stop, Restart, Deploy, NewFeature, Workspace, Draft, DraftFile, DraftValidate, DraftDiscard, Chat, Corrections, Playbooks } private data class Chip(val key: String, val label: String, val danger: Boolean = false) @@ -247,6 +254,7 @@ fun ProjectScreen( cols = cols, showBraise = showBraise, onBraise = { sheet = ProjectSheet.Chat }, + onPlaybooks = { actions.onOpenPlaybooks(); sheet = ProjectSheet.Playbooks }, onWorkspace = openCorrections, onPreview = actions.onOpenPreview, onDeploys = { sheet = ProjectSheet.Deploys }, @@ -331,6 +339,12 @@ fun ProjectScreen( onRestore = actions.onDraftRestore, onOpenPreview = { actions.onOpenPreview(); close() }, ) + ProjectSheet.Playbooks -> PlaybooksSheet( + state, + onDismiss = { actions.onClosePlaybooks(); close() }, + onRun = actions.onRunPlaybook, + onWake = actions.onWakeGrok, + ) ProjectSheet.Corrections -> CorrectionsSheet( state, onDismiss = close, @@ -584,6 +598,7 @@ fun ProjectTiles( onDeploys: () -> Unit, onSettings: () -> Unit, onPreview: () -> Unit = {}, + onPlaybooks: () -> Unit = {}, ) { val draft = state.draft val latest = state.latest @@ -604,6 +619,13 @@ fun ProjectTiles( val when_ = latest?.let { relativeTime(it.createdAt) }?.takeIf { it.isNotEmpty() } add(TileSpec("deploys", "Déploiements", listOfNotNull(dl, when_).joinToString(" · "), Icons.Filled.History, dc, latest?.isFailed == true, onDeploys)) + val pb = state.playbookRun + val (pbSub, pbTone) = when { + pb == null -> "Déployer, enquêter, essaim…" to DfColors.InkMuted + pb.live -> pb.statusText.ifBlank { "En cours…" } to DfColors.Accent + else -> app.jeser.devforge.data.verdictLabel(pb.verdict) to playbookVerdictTone(pb.verdict) + } + add(TileSpec("playbooks", "Modèles", pbSub, Icons.Filled.AutoAwesome, pbTone, pb?.needsHelp == true, onPlaybooks)) add(TileSpec("settings", "Paramètres", "Domaine, GitHub, équipe", Icons.Filled.Settings, DfColors.InkMuted, false, onSettings)) } Column(Modifier.fillMaxWidth().testTag("tiles"), verticalArrangement = Arrangement.spacedBy(10.dp)) { 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 5859aebe3a..6f6c024596 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 @@ -104,6 +104,13 @@ data class ProjectUiState( val undo: DraftUndo? = null, /** P5-02 : images du composer (envoyées dès l'ajout). */ val pendingImages: List = emptyList(), + /** Modèles d'agent : catalogue (chargé à l'ouverture), run suivi en direct, réveil du bot Grok. */ + val playbooks: List? = null, + val playbookRun: app.jeser.devforge.data.RunLive? = null, + val playbookBusy: String? = null, + val playbookError: String? = null, + val grokWaking: Boolean = false, + val grokWake: app.jeser.devforge.data.GrokWakeResult? = null, ) { /** Dernière mise en ligne de la prod (la dev a sa propre section Branches). */ val latest: Deployment? get() = deployments.firstOrNull { !it.isDev } ?: deployments.firstOrNull() @@ -688,6 +695,66 @@ class ProjectViewModel(private val graph: AppGraph, val uuid: String) : ViewMode runCatching { api.corrections(uuid) }.onSuccess { c -> _state.update { s -> if (s.corrections == c) s else s.copy(corrections = c) } } } + /* ---------------- Modèles d'agent ---------------- */ + + private var livePoll: Job? = null + private var liveRunUuid: String? = null + + fun openPlaybooks() { + _state.update { it.copy(playbookError = null) } + if (_state.value.playbooks == null) { + viewModelScope.launch { + runCatching { api.playbooks() } + .onSuccess { list -> _state.update { it.copy(playbooks = list) } } + .onFailure { e -> _state.update { it.copy(playbookError = e.message ?: "Modèles indisponibles") } } + } + } + liveRunUuid?.let { followRun(it) } + } + + fun closePlaybooks() { + livePoll?.cancel() + livePoll = null + } + + fun runPlaybook(id: String, goal: String?) { + if (_state.value.playbookBusy != null) return + _state.update { it.copy(playbookBusy = id, playbookError = null, grokWake = null) } + viewModelScope.launch { + runCatching { api.runPlaybook(uuid, id, goal) } + .onSuccess { r -> + _state.update { it.copy(playbookBusy = null, notice = "${r.title ?: id} lancé") } + followRun(r.runUuid) + } + .onFailure { e -> _state.update { it.copy(playbookBusy = null, playbookError = e.message ?: "Lancement impossible") } } + } + } + + /** Sonde la carte live toutes les 2,5 s tant que le run (ou un worker) travaille. */ + private fun followRun(runUuid: String) { + liveRunUuid = runUuid + livePoll?.cancel() + livePoll = viewModelScope.launch { + while (isActive) { + val live = runCatching { api.runLive(runUuid) }.getOrNull() + if (live != null) _state.update { s -> if (s.playbookRun == live) s else s.copy(playbookRun = live) } + if (live != null && !live.live) break + delay(if (live == null) 5_000 else 2_500) + } + } + } + + fun wakeGrok() { + if (_state.value.grokWaking) return + val run = _state.value.playbookRun?.runUuid + _state.update { it.copy(grokWaking = true, grokWake = null) } + viewModelScope.launch { + val r = runCatching { api.wakeGrokBot(uuid, run) } + .getOrElse { e -> app.jeser.devforge.data.GrokWakeResult(ok = false, message = e.message ?: "Réveil impossible") } + _state.update { it.copy(grokWaking = false, grokWake = r) } + } + } + fun openCorrections() { _state.update { it.copy(correctionConflict = null) } openDraft() diff --git a/apps/android/app/src/test/java/app/jeser/devforge/PlaybooksTest.kt b/apps/android/app/src/test/java/app/jeser/devforge/PlaybooksTest.kt new file mode 100644 index 0000000000..90940f77e9 --- /dev/null +++ b/apps/android/app/src/test/java/app/jeser/devforge/PlaybooksTest.kt @@ -0,0 +1,65 @@ +package app.jeser.devforge + +import app.jeser.devforge.data.DataEnvelope +import app.jeser.devforge.data.DevForgeJson +import app.jeser.devforge.data.GrokWakeResult +import app.jeser.devforge.data.LiveCounters +import app.jeser.devforge.data.Playbook +import app.jeser.devforge.data.RunLive +import app.jeser.devforge.data.evidenceUrl +import app.jeser.devforge.data.livePills +import app.jeser.devforge.data.needsHelp +import app.jeser.devforge.data.stepStateLabel +import app.jeser.devforge.data.workerStepState +import kotlinx.serialization.builtins.ListSerializer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlaybooksTest { + @Test fun parsesCatalog() { + val raw = """{"data":[{"id":"deploy-verify","slash":"/deploy-verify","name":"Déployer & vérifier", + "description":"d","steps":["a","b","c"],"rule":"r","visual":"rocket","goal_label":"","goal_placeholder":"", + "uses_workers":false,"extra":1}]}""" + val list = DevForgeJson.decodeFromString(DataEnvelope.serializer(ListSerializer(Playbook.serializer())), raw).data + assertEquals(1, list.size) + assertEquals("/deploy-verify", list[0].slash) + assertEquals(3, list[0].steps.size) + assertFalse(list[0].usesWorkers) + } + + @Test fun parsesLiveCard() { + val raw = """{"data":{"run_uuid":"r1","project_uuid":"p1","status":"completed","live":false,"verdict":"incomplete", + "status_text":"Terminé — 1 étape(s) sans résultat","title":"/swarm · Essaim","error":null, + "playbook":{"id":"swarm","slash":"/swarm","name":"Essaim","rule":"r","visual":"swarm"}, + "steps":[{"n":1,"label":"Découper","state":"ok"},{"n":2,"label":"Lancer","state":"ok"},{"n":3,"label":"Synthèse","state":"missing"}], + "workers":[{"run_uuid":"w1","title":"A","agent_name":"Worker","status":"completed","live":false,"summary":"s","error":null}], + "counters":{"tools_ok":4,"tools_failed":1,"workers_total":1,"workers_done":1,"workers_failed":0,"decisions":1}, + "decisions":[{"what":"x","why":"y","evidence":"https://e.fr/a","outcome":"ok","step":1}]}}""" + val live = DevForgeJson.decodeFromString(DataEnvelope.serializer(RunLive.serializer()), raw).data + assertEquals("missing", live.steps[2].state) + assertEquals("Sans résultat", stepStateLabel(live.steps[2].state)) + assertEquals("ok", workerStepState(live.workers[0].status)) + assertTrue(live.needsHelp) + assertEquals(listOf("Workers 1/1", "4 actions OK", "1 échec", "1 décision"), livePills(live.counters)) + } + + @Test fun pillsAndEvidence() { + assertEquals(listOf("0 action OK", "0 décision"), livePills(LiveCounters())) + assertEquals("https://e.fr/a", evidenceUrl("https://e.fr/a")) + assertNull(evidenceUrl("src/main.rs")) + assertNull(evidenceUrl("javascript:alert(1)")) + assertEquals("failed", workerStepState("cancelled")) + } + + @Test fun parsesWakeResult() { + val r = DevForgeJson.decodeFromString( + DataEnvelope.serializer(GrokWakeResult.serializer()), + """{"data":{"ok":true,"status":200,"message":"Bot réveillé","event":"devforge.wake"}}""", + ).data + assertTrue(r.ok) + assertEquals(200, r.status) + } +} diff --git a/apps/server/src/db.rs b/apps/server/src/db.rs index 74b08a8b02..b72f27c116 100644 --- a/apps/server/src/db.rs +++ b/apps/server/src/db.rs @@ -1026,6 +1026,8 @@ pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::Error> { crate::run_events::migrate(pool).await?; crate::receipts::migrate(pool).await?; crate::page_watch::migrate(pool).await?; + crate::playbooks::migrate(pool).await?; + crate::grok_bot::migrate(pool).await?; // Chat riche (P5) : réactions, pièces jointes, citations, routines d'agent. crate::rich_chat::migrate(pool).await?; diff --git a/apps/server/src/grok_bot.rs b/apps/server/src/grok_bot.rs new file mode 100644 index 0000000000..a61eaa5922 --- /dev/null +++ b/apps/server/src/grok_bot.rs @@ -0,0 +1,468 @@ +//! Réveiller un bot Grok depuis DevForge : URL de webhook + clé d'envoi, par espace. +//! L'URL et la clé sont scellées comme les autres secrets (`secret_box`) et jamais renvoyées. +//! Envoi : POST JSON générique `{event, project, run, message}`, en-têtes +//! `Authorization: Bearer ` et `X-Automation-Key: `, 8 s, un seul essai, +//! client gardé (pas d'adresse interne, pas de redirection, pas de proxy) comme page-watch. + +use std::time::Duration; + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sqlx::PgPool; + +use crate::page_watch::{self, FetchPolicy, BLOCKED_MESSAGE}; +use crate::routes::ApiError; + +pub const WAKE_TIMEOUT: Duration = Duration::from_secs(8); +pub const MIN_INTERVAL_SECS: i64 = 5; +pub const USER_AGENT: &str = "DevForge-GrokBot/1.0 (+https://web.jeser.app)"; +const MAX_MESSAGE: usize = 2000; + +pub fn router() -> Router { + Router::new() + .route( + "/api/v1/grok-bot/settings", + get(get_settings).patch(patch_settings), + ) + .route("/api/v1/grok-bot/wake", post(wake)) + .route("/api/v1/grok-bot/test", post(test)) +} + +pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::Error> { + sqlx::query( + r#"CREATE TABLE IF NOT EXISTS grok_bot_settings ( + workspace_uuid TEXT PRIMARY KEY, + url_sealed TEXT NOT NULL DEFAULT '', + key_sealed TEXT NOT NULL DEFAULT '', + last_ok BOOLEAN NULL, + last_status INT NULL, + last_message TEXT NULL, + last_event TEXT NULL, + last_at TIMESTAMPTZ NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + )"#, + ) + .execute(pool) + .await?; + Ok(()) +} + +#[derive(Debug, Clone, Default)] +pub struct Settings { + pub url: String, + pub key: String, + pub last_ok: Option, + pub last_status: Option, + pub last_message: Option, + pub last_event: Option, + pub last_at: Option, +} + +type Row = ( + String, + String, + Option, + Option, + Option, + Option, + Option, +); + +pub async fn load(pool: &PgPool, workspace: &str) -> Settings { + let row: Option = sqlx::query_as( + r#"SELECT url_sealed, key_sealed, last_ok, last_status, last_message, last_event, last_at::text + FROM grok_bot_settings WHERE workspace_uuid = $1"#, + ) + .bind(workspace) + .fetch_optional(pool) + .await + .ok() + .flatten(); + let Some((u, k, last_ok, last_status, last_message, last_event, last_at)) = row else { + return Settings::default(); + }; + let open = |s: &str| { + if s.is_empty() { + String::new() + } else { + crate::secret_box::open_compat(s) + } + }; + Settings { + url: open(&u), + key: open(&k), + last_ok, + last_status, + last_message, + last_event, + last_at, + } +} + +/// Hôte seul (jamais le chemin, qui peut porter l'identifiant de la routine). +pub fn url_hint(url: &str) -> Option { + let u = reqwest::Url::parse(url).ok()?; + Some(format!("{}://{}/…", u.scheme(), u.host_str()?)) +} + +/// Réponse publique : jamais l'URL complète ni la clé. +pub fn settings_json(s: &Settings) -> Value { + json!({ + "configured": !s.url.is_empty() && !s.key.is_empty(), + "url_set": !s.url.is_empty(), + "url_hint": url_hint(&s.url), + "key_set": !s.key.is_empty(), + "last": s.last_at.as_ref().map(|at| json!({ + "ok": s.last_ok, + "status": s.last_status, + "message": s.last_message, + "event": s.last_event, + "at": at, + })), + }) +} + +/// URL de webhook : https publique (http seulement en test loopback), sans identifiants. +pub fn validate_webhook_url(raw: &str, policy: &FetchPolicy) -> Result { + let url = page_watch::validate_url(raw, policy).map_err(|e| { + if e == BLOCKED_MESSAGE { + "Adresse interne refusée : le webhook doit être une adresse publique.".to_string() + } else { + e.replace("la page", "le webhook") + } + })?; + if url.scheme() != "https" && !policy.allow_loopback { + return Err("Le webhook doit être en https.".into()); + } + Ok(url) +} + +/// Clé d'envoi : une ligne, sans espace, bornée. +pub fn validate_key(raw: &str) -> Result { + let k = raw.trim(); + if k.len() < 8 || k.len() > 512 || k.chars().any(|c| c.is_whitespace() || c.is_control()) { + return Err("Clé d'envoi invalide (une seule ligne, sans espace).".into()); + } + Ok(k.to_string()) +} + +/// Corps générique documenté (docs/plans/agent-playbooks.md). +pub fn payload(event: &str, project: Value, run: Value, message: &str) -> Value { + let message: String = message.trim().chars().take(MAX_MESSAGE).collect(); + json!({ + "event": event, + "project": project, + "run": run, + "message": message, + }) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct WakeResult { + pub ok: bool, + pub status: Option, + pub message: String, +} + +/// Un seul POST, 8 s, client gardé. Le corps de réponse n'est jamais relu ni renvoyé. +pub async fn send(url: &str, key: &str, body: &Value, policy: &FetchPolicy) -> WakeResult { + let fail = |m: &str| WakeResult { + ok: false, + status: None, + message: m.to_string(), + }; + let url = match validate_webhook_url(url, policy) { + Ok(u) => u, + Err(e) => return fail(&e), + }; + let client = match page_watch::guarded_client(policy, USER_AGENT) { + Ok(c) => c, + Err(e) => return fail(&e), + }; + let req = client + .post(url) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header(reqwest::header::AUTHORIZATION, format!("Bearer {key}")) + .header("X-Automation-Key", key) + .body(body.to_string()); + match tokio::time::timeout(policy.timeout, req.send()).await { + Err(_) => fail("Le bot ne répond pas (délai de 8 s dépassé)."), + Ok(Err(e)) if page_watch::is_blocked_error(&e) => { + fail("Adresse interne refusée : le webhook doit être une adresse publique.") + } + Ok(Err(e)) if e.is_timeout() => fail("Le bot ne répond pas (délai de 8 s dépassé)."), + Ok(Err(_)) => fail("Connexion impossible au webhook."), + Ok(Ok(resp)) => { + let st = resp.status(); + let code = st.as_u16(); + let message = if st.is_success() { + format!("Bot réveillé (HTTP {code}) : demande acceptée, il travaille maintenant.") + } else if st.is_redirection() { + format!( + "Le webhook redirige (HTTP {code}) : redirection non suivie, vérifie l'URL." + ) + } else if code == 401 || code == 403 { + format!("Clé d'envoi refusée (HTTP {code}).") + } else if code == 404 { + "Webhook introuvable (HTTP 404) : vérifie l'URL.".to_string() + } else if code == 429 { + "Trop de réveils (HTTP 429) : réessaie dans un instant.".to_string() + } else { + format!("Le bot a refusé la demande (HTTP {code}).") + }; + WakeResult { + ok: st.is_success(), + status: Some(code), + message, + } + } + } +} + +fn policy() -> FetchPolicy { + FetchPolicy { + timeout: WAKE_TIMEOUT, + max_bytes: 0, + allow_loopback: false, + } +} + +async fn record(pool: &PgPool, workspace: &str, event: &str, r: &WakeResult) { + let _ = sqlx::query( + r#"UPDATE grok_bot_settings SET last_ok = $2, last_status = $3, last_message = $4, + last_event = $5, last_at = now() WHERE workspace_uuid = $1"#, + ) + .bind(workspace) + .bind(r.ok) + .bind(r.status.map(|s| s as i32)) + .bind(&r.message) + .bind(event) + .execute(pool) + .await; +} + +/// Anti-rafale : un réveil au plus toutes les 5 s par espace. +async fn too_soon(pool: &PgPool, workspace: &str) -> bool { + sqlx::query_scalar::<_, bool>( + "SELECT COALESCE(last_at > now() - make_interval(secs => $2), FALSE) FROM grok_bot_settings WHERE workspace_uuid = $1", + ) + .bind(workspace) + .bind(MIN_INTERVAL_SECS as f64) + .fetch_optional(pool) + .await + .ok() + .flatten() + .unwrap_or(false) +} + +async fn get_settings( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let (_u, ws) = crate::routes::require_auth(&state, &headers).await?; + Ok(Json( + json!({"data": settings_json(&load(&state.pool, &ws.uuid).await)}), + )) +} + +#[derive(Deserialize)] +struct PatchBody { + /// Absent = ne pas toucher ; vide = effacer. + #[serde(default)] + url: Option, + /// Absente = ne pas toucher ; vide = effacer. Jamais renvoyée. + #[serde(default)] + key: Option, +} + +async fn patch_settings( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result, ApiError> { + let (_u, ws) = crate::routes::require_auth(&state, &headers).await?; + if body.url.is_none() && body.key.is_none() { + return Err(ApiError::message("Rien à modifier.")); + } + let current = load(&state.pool, &ws.uuid).await; + let url = match body.url.as_deref().map(str::trim) { + None => current.url.clone(), + Some("") => String::new(), + Some(u) => validate_webhook_url(u, &policy()) + .map_err(ApiError::message)? + .to_string(), + }; + let key = match body.key.as_deref().map(str::trim) { + None => current.key.clone(), + Some("") => String::new(), + Some(k) => validate_key(k).map_err(ApiError::message)?, + }; + let seal = |s: &str| { + if s.is_empty() { + String::new() + } else { + crate::secret_box::seal_compat(s) + } + }; + sqlx::query( + r#"INSERT INTO grok_bot_settings (workspace_uuid, url_sealed, key_sealed, updated_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (workspace_uuid) DO UPDATE SET url_sealed = $2, key_sealed = $3, updated_at = now()"#, + ) + .bind(&ws.uuid) + .bind(seal(&url)) + .bind(seal(&key)) + .execute(&state.pool) + .await + .map_err(ApiError::from)?; + Ok(Json( + json!({"data": settings_json(&load(&state.pool, &ws.uuid).await)}), + )) +} + +#[derive(Deserialize, Default)] +struct WakeBody { + project_uuid: String, + #[serde(default)] + run_uuid: Option, + #[serde(default)] + deployment_uuid: Option, + #[serde(default)] + message: Option, +} + +fn one_line(s: &str, max: usize) -> String { + s.split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(max) + .collect() +} + +async fn dispatch( + state: &crate::state::AppState, + workspace: &str, + event: &str, + body: Value, +) -> Result, ApiError> { + let s = load(&state.pool, workspace).await; + if s.url.is_empty() || s.key.is_empty() { + return Err(ApiError::message( + "Bot Grok non configuré : ajoute l'URL du webhook et la clé d'envoi dans Réglages.", + )); + } + if too_soon(&state.pool, workspace).await { + return Err(ApiError { + status: StatusCode::TOO_MANY_REQUESTS, + message: "Bot déjà réveillé à l'instant : réessaie dans quelques secondes.".into(), + }); + } + let r = send(&s.url, &s.key, &body, &policy()).await; + record(&state.pool, workspace, event, &r).await; + Ok(Json(json!({"data": { + "ok": r.ok, + "status": r.status, + "message": r.message, + "event": event, + }}))) +} + +async fn wake( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result, ApiError> { + let (_u, ws, project) = + crate::routes::auth_project(&state, &headers, &body.project_uuid).await?; + let project_json = json!({ + "uuid": project.uuid, + "name": project.name, + "url": project.production_url, + }); + let mut event = "devforge.wake"; + let mut auto = format!("DevForge : un coup de main sur « {} ».", project.name); + let mut run_json = Value::Null; + if let Some(run_uuid) = body.run_uuid.as_deref().filter(|s| !s.is_empty()) { + let row: Option<(String, Option, Option, Option)> = sqlx::query_as( + "SELECT status, title, error, summary FROM agent_runs WHERE uuid = $1 AND project_uuid = $2", + ) + .bind(run_uuid) + .bind(&project.uuid) + .fetch_optional(&state.pool) + .await + .map_err(ApiError::from)?; + let (status, title, error, summary) = row.ok_or_else(|| ApiError::not_found("run"))?; + if matches!(status.as_str(), "failed" | "cancelled") { + event = "devforge.run_failed"; + auto = format!( + "DevForge : « {} » a échoué sur « {} » : {}", + title.clone().unwrap_or_else(|| "run".into()), + project.name, + one_line(error.as_deref().unwrap_or("erreur inconnue"), 400) + ); + } + run_json = json!({ + "type": "agent_run", + "uuid": run_uuid, + "status": status, + "title": title, + "error": error.as_deref().map(|e| one_line(e, 400)), + "summary": summary.as_deref().map(|e| one_line(e, 400)), + }); + } else if let Some(dep) = body.deployment_uuid.as_deref().filter(|s| !s.is_empty()) { + let row: Option<(String, Option, Option)> = sqlx::query_as( + "SELECT status, git_message, error_summary FROM deployments WHERE uuid = $1 AND project_id = $2", + ) + .bind(dep) + .bind(project.id) + .fetch_optional(&state.pool) + .await + .unwrap_or(None); + let (status, commit, err) = row.ok_or_else(|| ApiError::not_found("deployment"))?; + if status == "failed" { + event = "devforge.deploy_failed"; + auto = format!( + "DevForge : la mise en ligne de « {} » a échoué : {}", + project.name, + one_line(err.as_deref().unwrap_or("erreur inconnue"), 400) + ); + } + run_json = json!({ + "type": "deployment", + "uuid": dep, + "status": status, + "title": commit.as_deref().map(|c| one_line(c, 200)), + "error": err.as_deref().map(|e| one_line(e, 400)), + }); + } + let message = body + .message + .as_deref() + .map(str::trim) + .filter(|m| !m.is_empty()) + .map(|m| m.to_string()) + .unwrap_or(auto); + let payload = payload(event, project_json, run_json, &message); + dispatch(&state, &ws.uuid, event, payload).await +} + +async fn test( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let (_u, ws) = crate::routes::require_auth(&state, &headers).await?; + let payload = payload( + "devforge.test", + Value::Null, + Value::Null, + "DevForge : test de réveil. Réponds simplement que tu as bien reçu ce message.", + ); + dispatch(&state, &ws.uuid, "devforge.test", payload).await +} diff --git a/apps/server/src/grok_bot_tests.rs b/apps/server/src/grok_bot_tests.rs new file mode 100644 index 0000000000..c058f06c56 --- /dev/null +++ b/apps/server/src/grok_bot_tests.rs @@ -0,0 +1,195 @@ +//! Réveil du bot Grok : SSRF refusé, en-têtes, corps générique, secrets jamais renvoyés. + +use std::time::Duration; + +use serde_json::{json, Value}; +use wiremock::matchers::{header, method, path}; +use wiremock::{Mock, MockServer, Request, ResponseTemplate}; + +use crate::grok_bot::*; +use crate::page_watch::FetchPolicy; + +fn strict() -> FetchPolicy { + FetchPolicy { + timeout: Duration::from_secs(3), + max_bytes: 0, + allow_loopback: false, + } +} + +fn local() -> FetchPolicy { + FetchPolicy { + allow_loopback: true, + ..strict() + } +} + +#[test] +fn webhook_url_refuses_internal_targets() { + for bad in [ + "https://127.0.0.1/hook", + "https://10.0.0.5/hook", + "https://192.168.1.10/hook", + "https://169.254.169.254/latest/meta-data", + "https://[::1]/hook", + "https://localhost/hook", + "https://api.localhost/hook", + "https://user:pass@example.com/hook", + "ftp://example.com/hook", + "http://example.com/hook", + "", + ] { + assert!(validate_webhook_url(bad, &strict()).is_err(), "{bad}"); + } + assert!( + validate_webhook_url("https://api2.cursor.sh/automations/webhook/abc", &strict()).is_ok() + ); +} + +#[test] +fn key_is_one_line() { + assert!(validate_key("short").is_err()); + assert!(validate_key("abc def ghi jkl").is_err()); + assert_eq!( + validate_key(" sk_live_0123456789 ").unwrap(), + "sk_live_0123456789" + ); +} + +#[test] +fn settings_never_expose_key_or_full_url() { + let s = Settings { + url: "https://api2.cursor.sh/automations/webhook/secret-id-123".into(), + key: "sk_supersecret_value".into(), + ..Default::default() + }; + let v = settings_json(&s); + let txt = v.to_string(); + assert!(!txt.contains("sk_supersecret_value")); + assert!(!txt.contains("secret-id-123")); + assert_eq!(v["configured"], true); + assert_eq!(v["key_set"], true); + assert_eq!(v["url_hint"], "https://api2.cursor.sh/…"); + assert_eq!(settings_json(&Settings::default())["configured"], false); +} + +#[test] +fn payload_is_generic_and_bounded() { + let p = payload( + "devforge.run_failed", + json!({"uuid": "p"}), + Value::Null, + &"x".repeat(5000), + ); + let keys: Vec<&String> = p.as_object().unwrap().keys().collect(); + assert_eq!(keys.len(), 4); + for k in ["event", "project", "run", "message"] { + assert!(p.get(k).is_some(), "{k}"); + } + assert_eq!(p["message"].as_str().unwrap().len(), 2000); +} + +#[tokio::test] +async fn wake_posts_json_with_both_key_headers() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/hook")) + .and(header("authorization", "Bearer key-12345678")) + .and(header("x-automation-key", "key-12345678")) + .and(header("content-type", "application/json")) + .respond_with(ResponseTemplate::new(200).set_body_string("accepted secret-echo")) + .expect(1) + .mount(&server) + .await; + let body = payload("devforge.test", Value::Null, Value::Null, "bonjour"); + let r = send( + &format!("{}/hook", server.uri()), + "key-12345678", + &body, + &local(), + ) + .await; + assert!(r.ok, "{r:?}"); + assert_eq!(r.status, Some(200)); + // Le corps de réponse n'est jamais relayé. + assert!(!r.message.contains("secret-echo")); + let reqs: Vec = server.received_requests().await.unwrap(); + let sent: Value = serde_json::from_slice(&reqs[0].body).unwrap(); + assert_eq!(sent["event"], "devforge.test"); + assert_eq!(sent["message"], "bonjour"); +} + +#[tokio::test] +async fn wake_refuses_loopback_and_does_not_follow_redirects() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(302).insert_header("location", "http://169.254.169.254/"), + ) + .mount(&server) + .await; + let body = payload("devforge.test", Value::Null, Value::Null, "x"); + // Politique de production : adresse loopback refusée avant tout envoi. + let r = send( + &format!("{}/hook", server.uri()), + "key-12345678", + &body, + &strict(), + ) + .await; + assert!(!r.ok); + assert!( + r.message.contains("Adresse interne refusée") || r.message.contains("https"), + "{}", + r.message + ); + assert!(server.received_requests().await.unwrap().is_empty()); + // Redirection : jamais suivie. + let r = send( + &format!("{}/hook", server.uri()), + "key-12345678", + &body, + &local(), + ) + .await; + assert!(!r.ok); + assert_eq!(r.status, Some(302)); + assert_eq!(server.received_requests().await.unwrap().len(), 1); +} + +#[tokio::test] +async fn wake_reports_refused_key() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + let body = payload("devforge.test", Value::Null, Value::Null, "x"); + let r = send( + &format!("{}/hook", server.uri()), + "key-12345678", + &body, + &local(), + ) + .await; + assert!(!r.ok); + assert!(r.message.contains("Clé d'envoi refusée")); + assert!(!r.message.contains("key-12345678")); +} + +#[tokio::test] +async fn settings_are_stored_per_workspace_and_loaded_back() { + let pool = devforge_database::ephemeral_pg().await; + crate::db::migrate(&pool).await.unwrap(); + assert!(load(&pool, "ws1").await.url.is_empty()); + sqlx::query("INSERT INTO grok_bot_settings (workspace_uuid, url_sealed, key_sealed) VALUES ('ws1', $1, $2)") + .bind(crate::secret_box::seal_compat("https://example.com/hook")) + .bind(crate::secret_box::seal_compat("key-12345678")) + .execute(&pool) + .await + .unwrap(); + let s = load(&pool, "ws1").await; + assert_eq!(s.url, "https://example.com/hook"); + assert_eq!(s.key, "key-12345678"); + assert!(load(&pool, "ws2").await.key.is_empty()); +} diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index c407431070..afb8089695 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -37,6 +37,9 @@ mod github_auth; mod github_checks; mod github_migration; mod github_repos; +mod grok_bot; +#[cfg(test)] +mod grok_bot_tests; mod group_routes; mod infra_routes; mod infra_sqlite; @@ -46,6 +49,9 @@ mod mcp_routes; mod memory_routes; mod mobile_routes; mod paths; +mod playbooks; +#[cfg(test)] +mod playbooks_tests; mod platform_sso; #[cfg(test)] mod platform_sso_tests; @@ -396,6 +402,8 @@ async fn main() -> Result<(), Box> { .merge(memory_routes::router()) .merge(run_routes::router()) .merge(receipts::router()) + .merge(playbooks::router()) + .merge(grok_bot::router()) .merge(page_watch_routes::router()) .merge(rich_chat::router()) .merge(wake_gate::router()) diff --git a/apps/server/src/page_watch.rs b/apps/server/src/page_watch.rs index 9e7761fc78..33c54b97cb 100644 --- a/apps/server/src/page_watch.rs +++ b/apps/server/src/page_watch.rs @@ -573,8 +573,10 @@ pub async fn fetch(raw_url: &str, policy: &FetchPolicy) -> Result Result { - let client = reqwest::Client::builder() +/// Client HTTP gardé : résolution DNS filtrée (pas d'adresse interne, pas de rebinding), +/// aucune redirection suivie, aucun proxy système. Réutilisé par le réveil du bot Grok. +pub fn guarded_client(policy: &FetchPolicy, user_agent: &str) -> Result { + reqwest::Client::builder() .dns_resolver(Arc::new(GuardedResolver { policy: policy.clone(), })) @@ -582,9 +584,18 @@ async fn fetch_inner(raw_url: &str, policy: &FetchPolicy) -> Result bool { + error_chain(e).0 +} + +async fn fetch_inner(raw_url: &str, policy: &FetchPolicy) -> Result { + let client = guarded_client(policy, USER_AGENT).map_err(FetchError::msg)?; let mut url = validate_url(raw_url, policy).map_err(|m| FetchError { blocked: m == BLOCKED_MESSAGE, diff --git a/apps/server/src/playbooks.rs b/apps/server/src/playbooks.rs new file mode 100644 index 0000000000..d4274550a4 --- /dev/null +++ b/apps/server/src/playbooks.rs @@ -0,0 +1,745 @@ +//! Modèles d'agents (playbooks) : une commande, une description, 3 étapes fixes, une règle. +//! Un bouton lance le modèle sur le coordinateur du projet (run ordinaire, mêmes baux SQL). +//! `GET /agent/runs/{run}/live` : carte de progression (étapes, workers, compteurs) et journal +//! « montre-moi ton travail » calculés depuis les événements du run et de ses workers. + +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::routes::ApiError; +use crate::state::{now_str, AppState}; + +pub fn router() -> Router { + Router::new() + .route("/api/v1/playbooks", get(list_playbooks)) + .route( + "/api/v1/projects/{project_uuid}/playbooks/{playbook_id}/run", + post(run_playbook), + ) + .route("/api/v1/agent/runs/{run_uuid}/live", get(run_live)) +} + +pub async fn migrate(pool: &sqlx::PgPool) -> Result<(), sqlx::Error> { + sqlx::query("ALTER TABLE agent_runs ADD COLUMN IF NOT EXISTS playbook TEXT NULL") + .execute(pool) + .await?; + Ok(()) +} + +pub const MISSING_RULE: &str = "Un résultat manquant ne compte jamais comme un succès."; +pub const EVIDENCE_RULE: &str = devforge_agent::decision::EVIDENCE_RULE; + +#[derive(Debug, Clone, Serialize)] +pub struct Playbook { + pub id: &'static str, + pub slash: &'static str, + pub name: &'static str, + pub description: &'static str, + pub steps: [&'static str; 3], + pub rule: &'static str, + /// Icône / teinte côté interface. + pub visual: &'static str, + /// Champ facultatif (une ligne) : libellé et exemple. Vide = aucun champ. + pub goal_label: &'static str, + pub goal_placeholder: &'static str, + /// Étapes 2-3 suivies par les workers délégués (essaim). + pub uses_workers: bool, + #[serde(skip)] + pub instructions: &'static str, +} + +pub const PLAYBOOKS: &[Playbook] = &[ + Playbook { + id: "deploy-verify", + slash: "/deploy-verify", + name: "Déployer & vérifier", + description: "Met l'app en ligne, suit le build jusqu'au bout et vérifie l'adresse publique.", + steps: [ + "Lancer la mise en ligne", + "Suivre le build jusqu'au bout", + "Vérifier l'adresse publique", + ], + rule: MISSING_RULE, + visual: "rocket", + goal_label: "", + goal_placeholder: "", + uses_workers: false, + instructions: "1) trigger_deploy (si un accord est demandé, attends-le). 2) Suis la mise en ligne avec list_deployments / get_deployment_logs jusqu'à un statut final. 3) http_smoke sur l'adresse publique du projet. Si une étape n'a pas de résultat vérifié, elle est en échec.", + }, + Playbook { + id: "investigate-deploy", + slash: "/figure-it-out", + name: "Enquêter sur un échec de mise en ligne", + description: "Lit les logs du dernier échec, trouve la cause avec une preuve et propose le correctif.", + steps: [ + "Lire les logs du dernier échec", + "Formuler la cause avec une preuve", + "Proposer ou appliquer le correctif", + ], + rule: EVIDENCE_RULE, + visual: "search", + goal_label: "", + goal_placeholder: "", + uses_workers: false, + instructions: "1) list_deployments puis get_deployment_logs du dernier échec (et get_container_logs si l'app tourne). 2) Cause en une phrase, preuve = chemin du fichier fautif ou lien du log. 3) Corrige en local (write_project_file, run_workdir_command pour le build) ou propose le correctif ; ne remets pas en ligne sans accord.", + }, + Playbook { + id: "watch-page", + slash: "/watch-page", + name: "Surveiller une page", + description: "Vérifie une page publique et planifie un contrôle récurrent qui t'alerte si elle casse.", + steps: [ + "Lire la page publique", + "Vérifier le contenu attendu", + "Planifier le contrôle récurrent", + ], + rule: MISSING_RULE, + visual: "eye", + goal_label: "Page à surveiller (facultatif)", + goal_placeholder: "https://… (par défaut : l'adresse publique de l'app)", + uses_workers: false, + instructions: "1) http_smoke sur la page (adresse publique du projet si aucune n'est donnée). 2) Vérifie le statut HTTP et un contenu attendu (titre, texte clé). 3) create_routine : contrôle récurrent de cette page qui alerte en cas d'échec. Adresse interne : refuse.", + }, + Playbook { + id: "swarm", + slash: "/swarm", + name: "Essaim d'agents parallèles", + description: "Découpe un objectif en tâches indépendantes et lance un worker par tâche, en parallèle.", + steps: [ + "Découper en tâches indépendantes", + "Lancer un worker par tâche", + "Rassembler les résultats", + ], + rule: MISSING_RULE, + visual: "swarm", + goal_label: "Objectif (facultatif)", + goal_placeholder: "Ex. : audit accessibilité, perfs et sécurité du site", + uses_workers: true, + instructions: "1) Découpe l'objectif en 2 à 4 tâches indépendantes (pas de fichier partagé). 2) Un delegate_task par tâche, consignes précises et critère de fin vérifiable. 3) Les résultats des workers arrivent en cartes dans le fil : ne les annonce jamais réussis avant leur fin.", + }, + Playbook { + id: "reflect", + slash: "/reflect", + name: "Retour d'expérience", + description: "Relit les dernières mises en ligne et retient les leçons utiles en mémoire.", + steps: [ + "Relire les dernières mises en ligne", + "Dégager les leçons", + "Les retenir en mémoire", + ], + rule: EVIDENCE_RULE, + visual: "brain", + goal_label: "", + goal_placeholder: "", + uses_workers: false, + instructions: "1) list_deployments (5 dernières) et get_deployment_logs des échecs. 2) Au plus 3 leçons, chacune avec sa preuve (lien ou chemin). 3) memory_write pour chaque leçon (portée projet).", + }, +]; + +pub fn find(id: &str) -> Option<&'static Playbook> { + PLAYBOOKS.iter().find(|p| p.id == id) +} + +/// Objectif d'une ligne : espaces normalisés, borné, sans balise de rôle. +pub fn clean_goal(raw: Option<&str>) -> String { + let one: String = raw + .unwrap_or("") + .split_whitespace() + .collect::>() + .join(" "); + one.chars().take(300).collect() +} + +/// Message envoyé au coordinateur : la commande, les 3 étapes, la règle, le journal. +pub fn prompt(p: &Playbook, goal: &str) -> String { + let mut s = format!("{} — {}\n", p.slash, p.name); + if !goal.is_empty() { + s.push_str(&format!("Objectif : {goal}\n")); + } + s.push_str("Étapes (dans l'ordre) :\n"); + for (i, step) in p.steps.iter().enumerate() { + s.push_str(&format!("{}. {step}\n", i + 1)); + } + s.push_str(&format!("Règle : {}\n", p.rule)); + s.push_str(&format!("Comment : {}\n", p.instructions)); + s.push_str( + "Journal : à la fin de chaque étape, appelle record_decision (step = 1, 2 ou 3 ; evidence = un lien ou un chemin ; outcome = ok, failed ou skipped). Une étape sans décision notée est affichée comme manquante.", + ); + s +} + +pub fn catalog_json() -> Value { + json!(PLAYBOOKS) +} + +async fn list_playbooks( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + crate::routes::require_auth(&state, &headers).await?; + Ok(Json(json!({"data": catalog_json()}))) +} + +#[derive(Deserialize, Default)] +struct RunBody { + #[serde(default)] + goal: Option, +} + +async fn run_playbook( + State(state): State, + headers: HeaderMap, + Path((project_uuid, playbook_id)): Path<(String, String)>, + body: Option>, +) -> Result, ApiError> { + crate::routes::auth_project(&state, &headers, &project_uuid).await?; + let p = find(&playbook_id).ok_or_else(|| ApiError::not_found("playbook"))?; + let goal = clean_goal(body.as_ref().and_then(|b| b.goal.as_deref())); + let agent = crate::db::ensure_coordinator_agent(&state.pool, &project_uuid) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::message("Aucun agent coordinateur pour ce projet."))?; + let busy: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM agent_runs WHERE agent_uuid = $1 AND status IN ('pending','queued','running')", + ) + .bind(&agent) + .fetch_one(&state.pool) + .await + .map_err(ApiError::from)?; + if busy > 0 { + return Err(ApiError { + status: StatusCode::CONFLICT, + message: "L'agent est déjà occupé : attends la fin de son tour en cours.".into(), + }); + } + let run_uuid = + crate::agent_runs::record_user_turn(&state.pool, &project_uuid, &agent, &prompt(p, &goal)) + .await + .map_err(ApiError::message)?; + let title = format!("{} · {}", p.slash, p.name); + sqlx::query("UPDATE agent_runs SET title = $1, playbook = $2, updated_at = $3 WHERE uuid = $4") + .bind(&title) + .bind(p.id) + .bind(now_str()) + .bind(&run_uuid) + .execute(&state.pool) + .await + .map_err(ApiError::from)?; + crate::run_routes::start_run_now( + state.clone(), + project_uuid.clone(), + agent.clone(), + run_uuid.clone(), + ); + Ok(Json(json!({"data": { + "run_uuid": run_uuid, + "agent_uuid": agent, + "playbook": p.id, + "title": title, + }}))) +} + +/* ---------------- Carte live + journal (calcul pur, testé) ---------------- */ + +#[derive(Debug, Clone, Default)] +pub struct RunLite { + pub uuid: String, + pub status: String, + pub title: Option, + pub error: Option, + pub summary: Option, + pub agent_name: Option, + pub live: bool, +} + +#[derive(Debug, Clone)] +pub struct EventLite { + pub run_uuid: String, + pub ty: String, + pub payload: Value, +} + +#[derive(Debug, Clone, Default)] +pub struct ReceiptLite { + pub uuid: String, + pub action_label: String, + pub target: String, + pub status: String, + pub detail: Option, + pub approved_by_label: Option, + pub created_at: String, +} + +fn is_ok_status(s: &str) -> bool { + s == "completed" +} + +fn is_failed_status(s: &str) -> bool { + matches!(s, "failed" | "cancelled") +} + +fn is_terminal(s: &str) -> bool { + is_ok_status(s) || is_failed_status(s) +} + +fn one_line(s: &str, max: usize) -> String { + let t: String = s.split_whitespace().collect::>().join(" "); + if t.chars().count() > max { + format!("{}…", t.chars().take(max).collect::()) + } else { + t + } +} + +/// Décisions notées (`record_decision` réussi) dans les événements, dans l'ordre. +pub fn decisions_from_events( + events: &[EventLite], + names: &dyn Fn(&str) -> Option, +) -> Vec { + events + .iter() + .filter(|e| e.ty == "tool_done") + .filter(|e| e.payload.get("name").and_then(Value::as_str) == Some("record_decision")) + .filter_map(|e| { + let r = e.payload.get("result")?; + if r.get("ok").and_then(Value::as_bool) != Some(true) { + return None; + } + let d = r.get("decision")?; + let s = |k: &str| d.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let evidence = s("evidence"); + if !devforge_agent::decision::is_evidence(&evidence) { + return None; + } + Some(json!({ + "source": "agent", + "run_uuid": e.run_uuid, + "agent_name": names(&e.run_uuid), + "what": s("what"), + "why": s("why"), + "evidence": evidence, + "outcome": s("outcome"), + "step": d.get("step").and_then(Value::as_i64), + })) + }) + .collect() +} + +/// Accords donnés pendant le run : réutilise les reçus (preuve = page des reçus). +pub fn decisions_from_receipts(receipts: &[ReceiptLite]) -> Vec { + receipts + .iter() + .map(|r| { + let outcome = match r.status.as_str() { + "done" => "ok", + "failed" | "refused" | "expired" => "failed", + "executing" | "pending" | "approved" => "running", + _ => "unknown", + }; + let what = if r.target.is_empty() { + r.action_label.clone() + } else { + format!("{} · {}", r.action_label, r.target) + }; + let why = match &r.approved_by_label { + Some(by) if !by.is_empty() => format!("Accord donné par {by}"), + _ => "Action soumise à accord".to_string(), + }; + json!({ + "source": "receipt", + "what": one_line(&what, 120), + "why": why, + "evidence": format!("/app/receipts#{}", r.uuid), + "outcome": outcome, + "step": Value::Null, + "detail": r.detail.as_deref().map(|d| one_line(d, 200)), + "at": r.created_at, + }) + }) + .collect() +} + +/// État d'une étape : `pending` | `running` | `ok` | `failed` | `skipped` | `missing`. +/// Un run terminé sans décision pour une étape → `missing` (jamais `ok`). +pub fn step_states( + run: &RunLite, + playbook: Option<&Playbook>, + decisions: &[Value], + workers: &[RunLite], +) -> Vec { + let Some(p) = playbook else { + return Vec::new(); + }; + let terminal = is_terminal(&run.status) && !workers.iter().any(|w| !is_terminal(&w.status)); + let mut out: Vec<(String, &'static str)> = Vec::new(); + let mut current_assigned = false; + for (i, label) in p.steps.iter().enumerate() { + let n = (i + 1) as i64; + let mine: Vec<&str> = decisions + .iter() + .filter(|d| d.get("step").and_then(Value::as_i64) == Some(n)) + .filter_map(|d| d.get("outcome").and_then(Value::as_str)) + .collect(); + let mut state: &'static str = if mine.contains(&"failed") { + "failed" + } else if mine.contains(&"ok") { + "ok" + } else if mine.contains(&"skipped") { + "skipped" + } else { + "" + }; + // Essaim : les étapes 2-3 suivent les workers réels. + if p.uses_workers && state.is_empty() && !workers.is_empty() { + let any_failed = workers.iter().any(|w| is_failed_status(&w.status)); + let all_done = workers.iter().all(|w| is_terminal(&w.status)); + state = match n { + 1 | 2 => "ok", + _ if all_done && any_failed => "failed", + _ if all_done => "ok", + _ => "running", + }; + } + if state.is_empty() { + state = if terminal { + if is_failed_status(&run.status) && !current_assigned { + current_assigned = true; + "failed" + } else { + "missing" + } + } else if !current_assigned { + current_assigned = true; + "running" + } else { + "pending" + }; + } + out.push((label.to_string(), state)); + } + out.into_iter() + .enumerate() + .map(|(i, (label, state))| json!({"n": i + 1, "label": label, "state": state})) + .collect() +} + +/// Libellé courant : outil en cours, sinon jalon en cours, sinon état du run. +pub fn status_text( + run: &RunLite, + events: &[EventLite], + workers: &[RunLite], + steps: &[Value], +) -> String { + let live_workers = workers.iter().filter(|w| !is_terminal(&w.status)).count(); + if !is_terminal(&run.status) { + let mut open: Vec<(String, String)> = Vec::new(); + let mut milestone: Option = None; + for e in events.iter().filter(|e| e.run_uuid == run.uuid) { + let id = e + .payload + .get("id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + match e.ty.as_str() { + "tool_start" => { + let label = e.payload.get("label").and_then(Value::as_str).unwrap_or(""); + let name = e + .payload + .get("name") + .and_then(Value::as_str) + .unwrap_or("outil"); + open.push(( + id, + if label.is_empty() { + name.to_string() + } else { + label.to_string() + }, + )); + } + "tool_done" => open.retain(|(i, _)| i != &id), + "milestone" => { + let st = e + .payload + .get("status") + .and_then(Value::as_str) + .unwrap_or("running"); + let label = e.payload.get("label").and_then(Value::as_str).unwrap_or(""); + milestone = (st == "running" && !label.is_empty()).then(|| label.to_string()); + } + _ => {} + } + } + if let Some((_, l)) = open.last() { + return format!("{}…", one_line(l, 80).trim_end_matches('…')); + } + if let Some(m) = milestone { + return format!("{}…", one_line(&m, 80)); + } + if run.status == "paused" { + return "En pause".into(); + } + if matches!(run.status.as_str(), "pending" | "queued") { + return "En file d'attente…".into(); + } + return "Réflexion…".into(); + } + if live_workers > 0 { + return format!( + "{live_workers} worker{} en cours…", + if live_workers > 1 { "s" } else { "" } + ); + } + let missing = steps + .iter() + .filter(|s| s.get("state").and_then(Value::as_str) == Some("missing")) + .count(); + match run.status.as_str() { + "completed" if missing > 0 => format!( + "Terminé — {missing} étape{} sans résultat", + if missing > 1 { "s" } else { "" } + ), + "completed" => "Terminé".into(), + "cancelled" => "Arrêté".into(), + _ => match &run.error { + Some(e) if !e.is_empty() => format!("Échec : {}", one_line(e, 100)), + _ => "Échec".into(), + }, + } +} + +/// Verdict global : `running` | `ok` | `failed` | `incomplete`. +pub fn verdict(run: &RunLite, steps: &[Value], workers: &[RunLite]) -> &'static str { + if !is_terminal(&run.status) || workers.iter().any(|w| !is_terminal(&w.status)) { + return "running"; + } + let st = |s: &str| { + steps + .iter() + .any(|x| x.get("state").and_then(Value::as_str) == Some(s)) + }; + if is_failed_status(&run.status) + || st("failed") + || workers.iter().any(|w| is_failed_status(&w.status)) + { + "failed" + } else if st("missing") { + "incomplete" + } else { + "ok" + } +} + +pub fn compute_live( + run: &RunLite, + playbook: Option<&Playbook>, + events: &[EventLite], + workers: &[RunLite], + receipts: &[ReceiptLite], +) -> Value { + let names = |uuid: &str| -> Option { + if uuid == run.uuid { + run.agent_name.clone() + } else { + workers + .iter() + .find(|w| w.uuid == uuid) + .and_then(|w| w.agent_name.clone()) + } + }; + let mut decisions = decisions_from_events(events, &names); + decisions.extend(decisions_from_receipts(receipts)); + let steps = step_states(run, playbook, &decisions, workers); + let tools_ok = events + .iter() + .filter(|e| { + e.ty == "tool_done" && e.payload.get("ok").and_then(Value::as_bool) == Some(true) + }) + .count(); + let tools_failed = events + .iter() + .filter(|e| { + e.ty == "tool_done" && e.payload.get("ok").and_then(Value::as_bool) == Some(false) + }) + .count(); + let workers_done = workers.iter().filter(|w| is_ok_status(&w.status)).count(); + let workers_failed = workers + .iter() + .filter(|w| is_failed_status(&w.status)) + .count(); + let workers_json: Vec = workers + .iter() + .map(|w| { + json!({ + "run_uuid": w.uuid, + "title": w.title, + "agent_name": w.agent_name, + "status": w.status, + "live": w.live, + "summary": w.summary.as_deref().map(|s| one_line(s, 160)), + "error": w.error.as_deref().map(|s| one_line(s, 160)), + }) + }) + .collect(); + let text = status_text(run, events, workers, &steps); + let v = verdict(run, &steps, workers); + let decisions_n = decisions.len(); + json!({ + "run_uuid": run.uuid, + "status": run.status, + "live": v == "running", + "verdict": v, + "status_text": text, + "title": run.title, + "error": run.error.as_deref().map(|e| one_line(e, 300)), + "playbook": playbook.map(|p| json!({ + "id": p.id, "slash": p.slash, "name": p.name, "rule": p.rule, "visual": p.visual, + })), + "steps": steps, + "workers": workers_json, + "counters": { + "tools_ok": tools_ok, + "tools_failed": tools_failed, + "workers_total": workers.len(), + "workers_done": workers_done, + "workers_failed": workers_failed, + "decisions": decisions_n, + }, + "decisions": decisions, + }) +} + +#[derive(sqlx::FromRow)] +struct RunRowLite { + uuid: String, + project_uuid: String, + status: String, + title: Option, + error: Option, + summary: Option, + agent_name: Option, + playbook: Option, + parent_run_uuid: Option, +} + +impl RunRowLite { + fn lite(&self) -> RunLite { + RunLite { + uuid: self.uuid.clone(), + status: self.status.clone(), + title: self.title.clone(), + error: self.error.clone(), + summary: self.summary.clone(), + agent_name: self.agent_name.clone(), + live: crate::run_events::is_live(&self.uuid), + } + } +} + +const RUN_SELECT: &str = r#"SELECT r.uuid, r.project_uuid, r.status, r.title, r.error, r.summary, + a.name AS agent_name, r.playbook, r.parent_run_uuid + FROM agent_runs r LEFT JOIN project_agents a ON a.uuid = r.agent_uuid"#; + +pub async fn live_json( + pool: &sqlx::PgPool, + run_uuid: &str, +) -> Result, sqlx::Error> { + let run: Option = sqlx::query_as(&format!("{RUN_SELECT} WHERE r.uuid = $1")) + .bind(run_uuid) + .fetch_optional(pool) + .await?; + let Some(run) = run else { + return Ok(None); + }; + let _ = &run.parent_run_uuid; + let workers: Vec = sqlx::query_as(&format!( + "{RUN_SELECT} WHERE r.parent_run_uuid = $1 ORDER BY r.created_at ASC LIMIT 20" + )) + .bind(run_uuid) + .fetch_all(pool) + .await?; + let mut ids: Vec = vec![run.uuid.clone()]; + ids.extend(workers.iter().map(|w| w.uuid.clone())); + let events: Vec<(String, String, String)> = sqlx::query_as( + r#"SELECT run_uuid, type, payload::text FROM agent_run_events + WHERE run_uuid = ANY($1) AND type IN ('tool_start','tool_done','milestone') + ORDER BY run_uuid, seq ASC LIMIT 4000"#, + ) + .bind(&ids) + .fetch_all(pool) + .await?; + let events: Vec = events + .into_iter() + .map(|(run_uuid, ty, p)| EventLite { + run_uuid, + ty, + payload: serde_json::from_str(&p).unwrap_or(Value::Null), + }) + .collect(); + let receipts: Vec<( + String, + String, + String, + String, + Option, + Option, + String, + )> = sqlx::query_as( + r#"SELECT uuid, action_label, target, status, detail, approved_by_label, created_at::text + FROM approval_receipts WHERE run_uuid = ANY($1) ORDER BY created_at ASC LIMIT 50"#, + ) + .bind(&ids) + .fetch_all(pool) + .await + .unwrap_or_default(); + let receipts: Vec = receipts + .into_iter() + .map( + |(uuid, action_label, target, status, detail, approved_by_label, created_at)| { + ReceiptLite { + uuid, + action_label, + target, + status, + detail, + approved_by_label, + created_at, + } + }, + ) + .collect(); + let lite = run.lite(); + let worker_lites: Vec = workers.iter().map(RunRowLite::lite).collect(); + let pb = run.playbook.as_deref().and_then(find); + let mut v = compute_live(&lite, pb, &events, &worker_lites, &receipts); + v["project_uuid"] = json!(run.project_uuid); + v["agent_name"] = json!(run.agent_name); + Ok(Some((run.project_uuid, v))) +} + +async fn run_live( + State(state): State, + headers: HeaderMap, + Path(run_uuid): Path, +) -> Result, ApiError> { + let project: Option = + sqlx::query_scalar("SELECT project_uuid FROM agent_runs WHERE uuid = $1") + .bind(&run_uuid) + .fetch_optional(&state.pool) + .await + .map_err(ApiError::from)?; + let project = project.ok_or_else(|| ApiError::not_found("run"))?; + crate::routes::auth_project(&state, &headers, &project).await?; + let (_, v) = live_json(&state.pool, &run_uuid) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::not_found("run"))?; + Ok(Json(json!({"data": v}))) +} diff --git a/apps/server/src/playbooks_tests.rs b/apps/server/src/playbooks_tests.rs new file mode 100644 index 0000000000..2759d2b83e --- /dev/null +++ b/apps/server/src/playbooks_tests.rs @@ -0,0 +1,264 @@ +//! Modèles d'agents : catalogue, carte live (étapes, workers, compteurs), journal. + +use serde_json::{json, Value}; + +use crate::playbooks::*; + +fn run(status: &str) -> RunLite { + RunLite { + uuid: "r1".into(), + status: status.into(), + title: Some("/deploy-verify · Déployer & vérifier".into()), + agent_name: Some("Braise".into()), + ..Default::default() + } +} + +fn ev(run: &str, ty: &str, payload: Value) -> EventLite { + EventLite { + run_uuid: run.into(), + ty: ty.into(), + payload, + } +} + +fn decision(run: &str, step: i64, outcome: &str, evidence: &str) -> EventLite { + ev( + run, + "tool_done", + json!({ + "type": "tool_done", "id": format!("d{step}"), "name": "record_decision", "ok": true, + "arguments": {}, + "result": {"ok": true, "decision": { + "what": format!("étape {step}"), "why": "parce que", "evidence": evidence, + "outcome": outcome, "step": step, + }}, + }), + ) +} + +fn states(v: &Value) -> Vec { + v["steps"] + .as_array() + .unwrap() + .iter() + .map(|s| s["state"].as_str().unwrap().to_string()) + .collect() +} + +#[test] +fn catalog_has_three_fixed_steps_a_rule_and_unique_commands() { + assert!(PLAYBOOKS.len() >= 5); + let mut ids = std::collections::HashSet::new(); + let mut slashes = std::collections::HashSet::new(); + for p in PLAYBOOKS { + assert!(ids.insert(p.id), "id en double {}", p.id); + assert!(slashes.insert(p.slash), "commande en double {}", p.slash); + assert!(p.slash.starts_with('/')); + assert!(p.steps.iter().all(|s| !s.is_empty())); + assert!(!p.rule.is_empty() && !p.description.is_empty()); + } + // Arène : non livrée (pas de choix de modèle par worker) — documentée comme limite. + assert!(find("arena").is_none()); + for id in ["deploy-verify", "investigate-deploy", "watch-page", "swarm"] { + assert!(find(id).is_some(), "{id}"); + } + // Les consignes internes ne sortent pas dans l'API. + let cat = catalog_json(); + assert!(cat[0].get("instructions").is_none()); + assert_eq!(cat[0]["steps"].as_array().unwrap().len(), 3); +} + +#[test] +fn prompt_carries_steps_rule_and_journal_instruction() { + let p = find("swarm").unwrap(); + let s = prompt(p, &clean_goal(Some(" audit\n perfs "))); + assert!(s.starts_with("/swarm")); + assert!(s.contains("Objectif : audit perfs")); + assert!(s.contains("1. Découper en tâches indépendantes")); + assert!(s.contains("3. Rassembler les résultats")); + assert!(s.contains(MISSING_RULE)); + assert!(s.contains("record_decision")); + assert_eq!(clean_goal(Some(&"x".repeat(900))).len(), 300); + assert_eq!(clean_goal(None), ""); +} + +#[test] +fn a_missing_result_never_counts_as_success() { + let p = find("deploy-verify"); + // Run terminé sans aucune décision notée : 3 étapes manquantes, verdict incomplet. + let v = compute_live(&run("completed"), p, &[], &[], &[]); + assert_eq!(states(&v), vec!["missing", "missing", "missing"]); + assert_eq!(v["verdict"], "incomplete"); + assert_eq!(v["live"], false); + assert!(v["status_text"] + .as_str() + .unwrap() + .contains("3 étapes sans résultat")); + + // Deux étapes prouvées, la troisième absente → toujours incomplet. + let evs = vec![ + decision("r1", 1, "ok", "/app/deployments"), + decision("r1", 2, "ok", "https://example.com/build/42"), + ]; + let v = compute_live(&run("completed"), p, &evs, &[], &[]); + assert_eq!(states(&v), vec!["ok", "ok", "missing"]); + assert_eq!(v["verdict"], "incomplete"); + + let mut all = evs.clone(); + all.push(decision("r1", 3, "ok", "https://example.com")); + let v = compute_live(&run("completed"), p, &all, &[], &[]); + assert_eq!(v["verdict"], "ok"); + assert_eq!(v["counters"]["decisions"], 3); +} + +#[test] +fn running_run_shows_current_step_and_tool_label() { + let p = find("deploy-verify"); + let evs = vec![ + decision("r1", 1, "ok", "/app/deployments"), + ev( + "r1", + "tool_start", + json!({"id": "t2", "name": "get_deployment_logs", "label": "Lecture des logs de build"}), + ), + ]; + let v = compute_live(&run("running"), p, &evs, &[], &[]); + assert_eq!(states(&v), vec!["ok", "running", "pending"]); + assert_eq!(v["status_text"], "Lecture des logs de build…"); + assert_eq!(v["live"], true); + + // Outil terminé, jalon en cours → le jalon devient le libellé. + let mut evs2 = evs.clone(); + evs2.push(ev( + "r1", + "tool_done", + json!({"id": "t2", "name": "get_deployment_logs", "ok": false, "result": {}}), + )); + evs2.push(ev( + "r1", + "milestone", + json!({"label": "Vérification", "status": "running"}), + )); + let v = compute_live(&run("running"), p, &evs2, &[], &[]); + assert_eq!(v["status_text"], "Vérification…"); + assert_eq!(v["counters"]["tools_failed"], 1); +} + +#[test] +fn failed_run_marks_current_step_failed() { + let mut r = run("failed"); + r.error = Some("build cassé\nnpm ERR".into()); + let v = compute_live( + &r, + find("deploy-verify"), + &[decision("r1", 1, "ok", "/app/x")], + &[], + &[], + ); + assert_eq!(states(&v), vec!["ok", "failed", "missing"]); + assert_eq!(v["verdict"], "failed"); + assert_eq!(v["status_text"], "Échec : build cassé npm ERR"); +} + +#[test] +fn journal_keeps_only_link_or_path_evidence_and_reuses_receipts() { + let bad = ev( + "r1", + "tool_done", + json!({"name": "record_decision", "ok": true, "result": {"ok": true, "decision": { + "what": "x", "why": "y", "evidence": "j'ai tout vérifié à la main", "outcome": "ok", "step": 1}}}), + ); + let refused = ev( + "r1", + "tool_done", + json!({"name": "record_decision", "ok": true, "result": {"ok": false, "error": "…"}}), + ); + let receipt = ReceiptLite { + uuid: "rc1".into(), + action_label: "Mise en ligne".into(), + target: "web".into(), + status: "done".into(), + approved_by_label: Some("Mathieu".into()), + created_at: "2026-10-30".into(), + ..Default::default() + }; + let v = compute_live( + &run("running"), + find("deploy-verify"), + &[bad, refused], + &[], + &[receipt], + ); + let d = v["decisions"].as_array().unwrap(); + assert_eq!(d.len(), 1); + assert_eq!(d[0]["source"], "receipt"); + assert_eq!(d[0]["evidence"], "/app/receipts#rc1"); + assert_eq!(d[0]["outcome"], "ok"); + assert_eq!(d[0]["what"], "Mise en ligne · web"); +} + +#[test] +fn swarm_tracks_real_workers() { + let p = find("swarm"); + let w = |id: &str, st: &str| RunLite { + uuid: id.into(), + status: st.into(), + title: Some(format!("tâche {id}")), + ..Default::default() + }; + // Coordinateur fini (il s'arrête après délégation), 1 worker sur 2 en cours. + let workers = vec![w("w1", "completed"), w("w2", "running")]; + let v = compute_live(&run("completed"), p, &[], &workers, &[]); + assert_eq!(states(&v), vec!["ok", "ok", "running"]); + assert_eq!(v["verdict"], "running"); + assert_eq!(v["status_text"], "1 worker en cours…"); + assert_eq!(v["counters"]["workers_total"], 2); + assert_eq!(v["counters"]["workers_done"], 1); + + let workers = vec![w("w1", "completed"), w("w2", "failed")]; + let v = compute_live(&run("completed"), p, &[], &workers, &[]); + assert_eq!(states(&v), vec!["ok", "ok", "failed"]); + assert_eq!(v["verdict"], "failed"); + + // Aucun worker lancé : rien n'est « ok » par défaut. + let v = compute_live(&run("completed"), p, &[], &[], &[]); + assert_eq!(states(&v), vec!["missing", "missing", "missing"]); +} + +#[tokio::test] +async fn live_endpoint_reads_events_children_and_receipts_from_postgres() { + let pool = devforge_database::ephemeral_pg().await; + crate::db::migrate(&pool).await.unwrap(); + for (uuid, parent, status, pb) in [ + ("run-p", None, "completed", Some("swarm")), + ("run-w1", Some("run-p"), "completed", None), + ("run-w2", Some("run-p"), "running", None), + ] { + sqlx::query( + r#"INSERT INTO agent_runs (uuid, project_uuid, agent_uuid, message_uuid, status, created_at, updated_at, kind, title, parent_run_uuid, playbook) + VALUES ($1, 'p1', 'a1', 'm-' || $1, $2, 'x', 'x', 'chat', $1, $3, $4)"#, + ) + .bind(uuid) + .bind(status) + .bind(parent) + .bind(pb) + .execute(&pool) + .await + .unwrap(); + } + let payload = json!({"type": "tool_done", "id": "d1", "name": "record_decision", "ok": true, "arguments": {}, + "result": {"ok": true, "decision": {"what": "Découpe", "why": "3 axes", "evidence": "docs/plan.md", "outcome": "ok", "step": 1}}}); + sqlx::query("INSERT INTO agent_run_events (run_uuid, seq, agent_uuid, type, payload) VALUES ('run-w1', 1, 'a1', 'tool_done', $1::jsonb)") + .bind(payload.to_string()) + .execute(&pool) + .await + .unwrap(); + let (project, v) = live_json(&pool, "run-p").await.unwrap().unwrap(); + assert_eq!(project, "p1"); + assert_eq!(v["playbook"]["id"], "swarm"); + assert_eq!(v["workers"].as_array().unwrap().len(), 2); + assert_eq!(v["decisions"][0]["evidence"], "docs/plan.md"); + assert_eq!(v["verdict"], "running"); + assert!(live_json(&pool, "absent").await.unwrap().is_none()); +} diff --git a/apps/server/src/run_routes.rs b/apps/server/src/run_routes.rs index 5df4cd2e5e..47d57457cd 100644 --- a/apps/server/src/run_routes.rs +++ b/apps/server/src/run_routes.rs @@ -51,12 +51,13 @@ struct RunInfo { error: Option, created_at: String, updated_at: String, + playbook: Option, } async fn load_run(state: &AppState, run_uuid: &str) -> Option { sqlx::query_as::<_, RunInfo>( r#"SELECT uuid, project_uuid, agent_uuid, status, kind, title, progress::text AS progress, summary, - parent_run_uuid, cancel_requested, attempts, error, created_at, updated_at + parent_run_uuid, cancel_requested, attempts, error, created_at, updated_at, playbook FROM agent_runs WHERE uuid = $1"#, ) .bind(run_uuid) @@ -104,6 +105,7 @@ async fn run_json(state: &AppState, r: &RunInfo) -> Value { "started_at": r.created_at, "updated_at": r.updated_at, "live": run_events::is_live(&r.uuid), + "playbook": r.playbook, }) } @@ -332,7 +334,7 @@ async fn active_runs( r#"SELECT r.uuid FROM agent_runs r JOIN projects p ON p.uuid = r.project_uuid WHERE p.workspace_uuid = $1 AND (r.status IN ('pending','queued','running','paused') - OR (r.status = 'failed' AND r.kind = 'worker' AND r.updated_at >= $2)) + OR (r.status = 'failed' AND (r.kind = 'worker' OR r.playbook IS NOT NULL) AND r.updated_at >= $2)) ORDER BY r.created_at DESC LIMIT 20"#, ) .bind(&workspace.uuid) diff --git a/apps/web/src/components/ActiveRuns.tsx b/apps/web/src/components/ActiveRuns.tsx index 71e4be6186..a4cdd42101 100644 --- a/apps/web/src/components/ActiveRuns.tsx +++ b/apps/web/src/components/ActiveRuns.tsx @@ -5,6 +5,8 @@ import { cn } from '../lib/cn'; import { jobStatusFromRun } from '../lib/jobs'; import { JobCard } from './chat/JobCard'; import { Modal } from './ui'; +import { RunLiveCard } from './RunLiveCard'; +import { WakeGrokButton } from './WakeGrokButton'; /** Runs actifs de l'espace, relus toutes les 10 s tant que l'onglet est visible. */ export function useActiveRuns(enabled = true, intervalMs = 10000): [AgentRunInfo[], () => void] { @@ -74,7 +76,20 @@ export function ActiveRunRows({ runs, onPick }: { runs: AgentRunInfo[]; onPick: export function ActiveRunModal({ run, onClose }: { run: AgentRunInfo | null; onClose: () => void }) { return ( - {run ? : null} + {run ? ( + run.playbook ? ( + + ) : ( + <> + + {run.status === 'failed' && ( +
+ +
+ )} + + ) + ) : null}
); } diff --git a/apps/web/src/components/GrokBotTile.tsx b/apps/web/src/components/GrokBotTile.tsx new file mode 100644 index 0000000000..ee26bf0297 --- /dev/null +++ b/apps/web/src/components/GrokBotTile.tsx @@ -0,0 +1,185 @@ +import { useEffect, useState } from 'preact/hooks'; +import { api, type GrokBotSettings } from '../lib/api'; +import { cn } from '../lib/cn'; +import { Alert, Button, HubIcon, HubTile, Input, Modal, Spinner, useToast } from './ui'; + +/** Même règle que le serveur : https uniquement (le serveur refuse aussi les adresses internes). */ +export function isValidWebhookUrl(value: string): boolean { + try { + const u = new URL(value.trim()); + return u.protocol === 'https:' && !!u.hostname && !u.username && !u.password; + } catch { + return false; + } +} + +/** + * Tuile « Bot Grok » : état (configuré / dernier réveil) dans la tuile, réglage dans une Modal. + * L'URL complète et la clé d'envoi ne reviennent jamais de l'API : champs en écriture seule. + */ +export function GrokBotTile({ index = 0 }: { index?: number }) { + const toast = useToast(); + const [data, setData] = useState(null); + const [open, setOpen] = useState(false); + const [url, setUrl] = useState(''); + const [key, setKey] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState<'save' | 'test' | 'clear' | null>(null); + const [testRes, setTestRes] = useState<{ ok: boolean; message: string } | null>(null); + + async function load() { + try { + setData((await api.grokBotSettings()).data); + } catch { + /* tuile affichée « non configuré » */ + } + } + + useEffect(() => { + void load(); + }, []); + + function openModal() { + setUrl(''); + setKey(''); + setError(null); + setTestRes(null); + setOpen(true); + } + + async function save(e: Event) { + e.preventDefault(); + const body: { url?: string; key?: string } = {}; + if (url.trim()) { + if (!isValidWebhookUrl(url)) { + setError('Adresse invalide : https://… public uniquement.'); + return; + } + body.url = url.trim(); + } + if (key.trim()) body.key = key.trim(); + if (!body.url && !body.key) { + setError(data?.configured ? 'Rien à modifier.' : 'Collez l’URL du webhook et la clé d’envoi.'); + return; + } + setBusy('save'); + setError(null); + try { + setData((await api.saveGrokBotSettings(body)).data); + setUrl(''); + setKey(''); + toast.push({ title: 'Bot Grok enregistré', tone: 'ok' }); + } catch (err) { + setError(String((err as Error).message || err)); + } finally { + setBusy(null); + } + } + + async function clear() { + setBusy('clear'); + try { + setData((await api.saveGrokBotSettings({ url: '', key: '' })).data); + toast.push({ title: 'Bot Grok déconnecté', tone: 'ok' }); + } catch (err) { + setError(String((err as Error).message || err)); + } finally { + setBusy(null); + } + } + + async function test() { + setBusy('test'); + setTestRes(null); + try { + const r = (await api.testGrokBot()).data; + setTestRes({ ok: r.ok, message: r.message }); + } catch (err) { + setTestRes({ ok: false, message: String((err as Error).message || err) }); + } finally { + setBusy(null); + void load(); + } + } + + const last = data?.last; + const subtitle = ( +
+ {data?.configured ? ( + <> + + {last?.ok === false ? 'Dernier réveil en échec' : 'Configuré'} + + {data.url_hint && · {data.url_hint}} + + ) : ( + Non configuré + )} +
+ ); + + return ( + <> + } subtitle={subtitle} onClick={openModal} /> + busy === null && setOpen(false)} title="Bot Grok" description="Réveiller votre bot quand un run ou un déploiement échoue" size="sm"> +
+
+
État
+
+ {data?.configured ? `Configuré${data.url_hint ? ` · ${data.url_hint}` : ''}` : 'Non configuré'} +
+ {last && ( +
+ Dernier envoi : {last.message || (last.ok ? 'OK' : 'Échec')} · {new Date(last.at).toLocaleString('fr-FR')} +
+ )} +
+ { + setUrl((e.target as HTMLInputElement).value); + setError(null); + }} + /> + { + setKey((e.target as HTMLInputElement).value); + setError(null); + }} + hint="Stockée chiffrée, jamais réaffichée." + /> + {error && {error}} + {testRes && {testRes.message}} +
+ {data?.configured && ( + + )} + {data?.configured && ( + + )} + +
+
+
+ + ); +} diff --git a/apps/web/src/components/PlaybooksTile.tsx b/apps/web/src/components/PlaybooksTile.tsx new file mode 100644 index 0000000000..e70ee60bd5 --- /dev/null +++ b/apps/web/src/components/PlaybooksTile.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from 'preact/hooks'; +import { Brain, Eye, Network, Rocket, Search } from 'lucide-preact'; +import { api, type Playbook } from '../lib/api'; +import { RunLiveCard } from './RunLiveCard'; +import { Button, HubIcon, HubTile, Input, Modal, Spinner, useToast } from './ui'; + +const VISUALS: Record = { + rocket: Rocket, + search: Search, + eye: Eye, + swarm: Network, + brain: Brain, +}; + +const lastRunKey = (projectUuid: string) => `df.playbook-run.${projectUuid}`; + +function readLastRun(projectUuid: string): string | null { + try { + return localStorage.getItem(lastRunKey(projectUuid)); + } catch { + return null; + } +} + +/** + * Tuile « Modèles » : bibliothèque de playbooks (commande slash, 3 étapes fixes, règle). + * Un bouton « Lancer » par modèle ; la carte live du dernier lancement s'affiche en tête. + */ +export function PlaybooksTile({ projectUuid, index = 0 }: { projectUuid: string; index?: number }) { + const toast = useToast(); + const [open, setOpen] = useState(false); + const [list, setList] = useState(null); + const [goals, setGoals] = useState>({}); + const [busy, setBusy] = useState(null); + const [runUuid, setRunUuid] = useState(() => readLastRun(projectUuid)); + + useEffect(() => { + if (!open || list) return; + api + .playbooks() + .then((r) => setList(r.data)) + .catch((e) => toast.push({ title: 'Modèles indisponibles', detail: String((e as Error).message || e), tone: 'danger' })); + }, [open]); + + async function launch(p: Playbook) { + setBusy(p.id); + try { + const goal = (goals[p.id] || '').trim(); + const r = await api.runPlaybook(projectUuid, p.id, goal || undefined); + setRunUuid(r.data.run_uuid); + try { + localStorage.setItem(lastRunKey(projectUuid), r.data.run_uuid); + } catch { + /* stockage indisponible : la carte reste visible tant que la modale est ouverte */ + } + toast.push({ title: `${p.slash} lancé`, detail: p.name, tone: 'ok' }); + } catch (e) { + toast.push({ title: 'Lancement impossible', detail: String((e as Error).message || e), tone: 'danger' }); + } finally { + setBusy(null); + } + } + + return ( + <> + } + onClick={() => setOpen(true)} + /> + setOpen(false)} + title="Modèles d'agent" + description="Un bouton, trois étapes fixes, une règle." + size="lg" + fullOnMobile + > +
+ {runUuid && ( +
+
+ Dernier lancement + +
+ +
+ )} + + {!list ? ( +
+ Chargement… +
+ ) : ( +
    + {list.map((p) => { + const Icon = VISUALS[p.visual] || Rocket; + return ( +
  • +
    + + + +
    +
    {p.slash}
    +
    {p.name}
    +
    +
    +

    {p.description}

    +
      + {p.steps.map((s, i) => ( +
    1. + + {i + 1} + + {s} +
    2. + ))} +
    +

    + Règle : {p.rule} +

    + {p.goal_label && ( +
    + setGoals((g) => ({ ...g, [p.id]: (e.target as HTMLInputElement).value }))} + /> +
    + )} +
    + +
    +
  • + ); + })} +
+ )} +
+
+ + ); +} diff --git a/apps/web/src/components/ProjectAgentsHub.tsx b/apps/web/src/components/ProjectAgentsHub.tsx index cf54e0ba38..4dce93d363 100644 --- a/apps/web/src/components/ProjectAgentsHub.tsx +++ b/apps/web/src/components/ProjectAgentsHub.tsx @@ -23,6 +23,7 @@ import { } from '../lib/launched-agents'; import { ProjectAgentsPanel } from './ProjectAgentsPanel'; import { ProjectSpecsTile } from './ProjectSpecsModal'; +import { PlaybooksTile } from './PlaybooksTile'; import { Alert, Badge, @@ -396,6 +397,7 @@ export function ProjectAgentsHub({ + {gridAgents.map((agent, i) => { const status = statusOf(agent); const isCoord = agent.role === 'coordinator'; diff --git a/apps/web/src/components/RunLiveCard.test.tsx b/apps/web/src/components/RunLiveCard.test.tsx new file mode 100644 index 0000000000..e1cdb585f3 --- /dev/null +++ b/apps/web/src/components/RunLiveCard.test.tsx @@ -0,0 +1,47 @@ +import { fireEvent, render, waitFor } from '@testing-library/preact'; +import { describe, expect, it, vi } from 'vitest'; +import type { RunLive } from '../lib/api'; + +const live: RunLive = { + run_uuid: 'r1', + project_uuid: 'p1', + status: 'completed', + live: false, + verdict: 'incomplete', + status_text: 'Terminé — 1 étape(s) sans résultat', + playbook: { id: 'deploy-verify', slash: '/deploy-verify', name: 'Déployer & vérifier', rule: 'Un résultat manquant ne compte jamais comme un succès.', visual: 'rocket' }, + steps: [ + { n: 1, label: 'Déployer', state: 'ok' }, + { n: 2, label: 'Vérifier', state: 'ok' }, + { n: 3, label: 'Conclure', state: 'missing' }, + ], + workers: [], + counters: { tools_ok: 2, tools_failed: 0, workers_total: 0, workers_done: 0, workers_failed: 0, decisions: 1 }, + decisions: [{ what: 'Déployé', why: 'Commit prêt', evidence: 'https://app.example.org/health', outcome: 'ok', step: 1 }], +}; + +const wake = vi.fn(async () => ({ data: { ok: true, status: 200, message: 'Bot réveillé (HTTP 200).', event: 'devforge.wake' } })); + +vi.mock('../lib/api', () => ({ + api: { + runLive: vi.fn(async () => ({ data: live })), + wakeGrokBot: (b: unknown) => wake(b as never), + }, +})); + +import { RunLiveCard } from './RunLiveCard'; + +describe('RunLiveCard', () => { + it('shows status, steps, rule, journal and wakes the bot', async () => { + const r = render(); + await r.findByText('Terminé — 1 étape(s) sans résultat'); + expect(r.getByText('Sans résultat')).toBeTruthy(); + expect(r.getByText(/Un résultat manquant/)).toBeTruthy(); + fireEvent.click(r.getByText('Montre-moi ton travail (1)')); + const link = r.getByText('https://app.example.org/health') as HTMLAnchorElement; + expect(link.getAttribute('rel')).toContain('noopener'); + fireEvent.click(r.getByText('Réveiller le bot Grok')); + await waitFor(() => expect(r.getByText('Bot réveillé (HTTP 200).')).toBeTruthy()); + expect(wake).toHaveBeenCalledWith({ project_uuid: 'p1', run_uuid: 'r1' }); + }); +}); diff --git a/apps/web/src/components/RunLiveCard.tsx b/apps/web/src/components/RunLiveCard.tsx new file mode 100644 index 0000000000..6f314de320 --- /dev/null +++ b/apps/web/src/components/RunLiveCard.tsx @@ -0,0 +1,226 @@ +import { useEffect, useState } from 'preact/hooks'; +import { api, type RunLive, type StepState } from '../lib/api'; +import { cn } from '../lib/cn'; +import { + LIVE_POLL_MS, + OUTCOME_LABEL, + STEP_LABEL, + VERDICT_LABEL, + counterPills, + evidenceLink, + needsHelp, + outcomeTone, + stepTone, + verdictTone, + workerState, + type Tone, +} from '../lib/run-live'; +import { Button, Spinner } from './ui'; +import { WakeGrokButton } from './WakeGrokButton'; + +const TONE_TEXT: Record = { + accent: 'text-[var(--color-accent)]', + ok: 'text-[var(--color-ok)]', + danger: 'text-[var(--color-danger)]', + warn: 'text-[var(--color-warn)]', + muted: 'text-[var(--color-ink-faint)]', +}; + +const TONE_PILL: Record = { + accent: 'border-[var(--color-accent)]/30 bg-[var(--color-accent)]/10 text-[var(--color-accent)]', + ok: 'border-emerald-500/25 bg-emerald-500/10 text-[var(--color-ok)]', + danger: 'border-red-500/25 bg-red-500/10 text-[var(--color-danger)]', + warn: 'border-amber-500/25 bg-amber-500/10 text-[var(--color-warn)]', + muted: 'border-[var(--color-line)] bg-white/[0.03] text-[var(--color-ink-muted)]', +}; + +/** Sonde `/live` toutes les 2,5 s tant que le run (ou un de ses workers) travaille. */ +export function useRunLive(runUuid: string | null): [RunLive | null, string | null] { + const [live, setLive] = useState(null); + const [error, setError] = useState(null); + useEffect(() => { + if (!runUuid) return; + let stop = false; + let timer: ReturnType | undefined; + const tick = async () => { + try { + const r = await api.runLive(runUuid); + if (stop) return; + setLive(r.data); + setError(null); + if (r.data.live) timer = setTimeout(tick, LIVE_POLL_MS); + } catch (e) { + if (stop) return; + setError(String((e as Error).message || e)); + timer = setTimeout(tick, LIVE_POLL_MS * 2); + } + }; + void tick(); + return () => { + stop = true; + if (timer) clearTimeout(timer); + }; + }, [runUuid]); + return [live, error]; +} + +function StepIcon({ state }: { state: StepState }) { + if (state === 'running') return ; + const glyph = { ok: '✓', failed: '✕', missing: '?', skipped: '–', pending: '•' }[state] ?? '•'; + return ( + + {glyph} + + ); +} + +/** + * Carte « Live » d'un run : texte d'état qui change, étapes / workers en grille, + * pastilles compteurs, journal « Montre-moi ton travail », bouton « Réveiller le bot Grok ». + */ +export function RunLiveCard({ runUuid, compact = false }: { runUuid: string; compact?: boolean }) { + const [live, error] = useRunLive(runUuid); + const [journal, setJournal] = useState(false); + + if (!live) { + return ( +
+ {error ? {error} : <> Chargement…} +
+ ); + } + + const vt = verdictTone(live.verdict); + const help = needsHelp(live); + + return ( +
+ {live.live && ( + + )} +
+ + + +
+
+ {live.live ? 'Analyse en direct' : VERDICT_LABEL[live.verdict] || live.verdict} + {live.playbook && {live.playbook.slash}} +
+

+ {live.status_text} +

+
+
+ +
+ {counterPills(live).map((p) => ( + + {p.label} + + ))} +
+ + {live.steps.length > 0 && ( +
    + {live.steps.map((s) => ( +
  1. + + {s.label} + {STEP_LABEL[s.state]} +
  2. + ))} +
+ )} + + {live.workers.length > 0 && ( +
    + {live.workers.map((w) => { + const st = workerState(w.status); + return ( +
  • + + {w.title || w.agent_name || 'Worker'} +
  • + ); + })} +
+ )} + + {live.playbook && !compact && ( +

+ Règle : {live.playbook.rule} +

+ )} + +
+ + +
+ + {journal && ( +
+ {live.decisions.length === 0 ? ( +

Aucune décision notée pour l'instant.

+ ) : ( + live.decisions.map((d, i) => { + const link = evidenceLink(d.evidence); + return ( +
+
+ {OUTCOME_LABEL[d.outcome] || d.outcome} + + {d.step ? `Étape ${d.step} · ` : ''} + {d.what} + +
+

{d.why}

+

+ {link ? ( + + {d.evidence} + + ) : ( + {d.evidence} + )} +

+
+ ); + }) + )} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/SettingsPage.tsx b/apps/web/src/components/SettingsPage.tsx index 82a4c814ae..aaeff3a108 100644 --- a/apps/web/src/components/SettingsPage.tsx +++ b/apps/web/src/components/SettingsPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'preact/hooks'; import { Eye } from 'lucide-preact'; import { api, type InstanceDomain } from '../lib/api'; import { AcmeEmailTile } from './AcmeEmailTile'; +import { GrokBotTile } from './GrokBotTile'; import { AppShell } from './AppShell'; import { GithubAppSettingsPanel } from './GithubAppSettingsPanel'; import { LlmProvidersPanel } from './LlmProvidersPanel'; @@ -291,6 +292,7 @@ function SettingsPageInner() { /> ))} {isAdmin && } +
); diff --git a/apps/web/src/components/WakeGrokButton.tsx b/apps/web/src/components/WakeGrokButton.tsx new file mode 100644 index 0000000000..c7bcb4b1f4 --- /dev/null +++ b/apps/web/src/components/WakeGrokButton.tsx @@ -0,0 +1,56 @@ +import { useState } from 'preact/hooks'; +import { api } from '../lib/api'; +import { cn } from '../lib/cn'; +import { Button, Spinner } from './ui'; + +type Props = { + projectUuid: string; + runUuid?: string; + deploymentUuid?: string; + variant?: 'primary' | 'outline' | 'ghost'; +}; + +/** Un bouton : POST vers le webhook du bot Grok configuré, résultat affiché juste dessous. */ +export function WakeGrokButton({ projectUuid, runUuid, deploymentUuid, variant = 'outline' }: Props) { + const [busy, setBusy] = useState(false); + const [res, setRes] = useState<{ ok: boolean; message: string } | null>(null); + + async function wake() { + setBusy(true); + setRes(null); + try { + const r = await api.wakeGrokBot({ + project_uuid: projectUuid, + ...(runUuid ? { run_uuid: runUuid } : {}), + ...(deploymentUuid ? { deployment_uuid: deploymentUuid } : {}), + }); + setRes({ ok: r.data.ok, message: r.data.message }); + } catch (e) { + setRes({ ok: false, message: String((e as Error).message || e) }); + } finally { + setBusy(false); + } + } + + return ( + + + {res && ( + + {res.message} + {!res.ok && /non configuré/i.test(res.message) && ( + <> + {' '} + + Réglages + + + )} + + )} + + ); +} diff --git a/apps/web/src/components/workspace/DeploymentsSheet.tsx b/apps/web/src/components/workspace/DeploymentsSheet.tsx index 6a8ba34f32..4a6f16109f 100644 --- a/apps/web/src/components/workspace/DeploymentsSheet.tsx +++ b/apps/web/src/components/workspace/DeploymentsSheet.tsx @@ -5,6 +5,7 @@ import { api, type Deployment } from '../../lib/api'; import { Badge, Button, Portal, Spinner } from '../ui'; import { cn } from '../../lib/cn'; import { DeployTimeline } from './DeployTimeline'; +import { WakeGrokButton } from '../WakeGrokButton'; type Props = { open: boolean; @@ -427,6 +428,7 @@ export function DeploymentsSheet({ open, onClose, projectUuid, variant = 'sheet' {retryError && ( {retryError} )} + )} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 0acf284cde..c640a6c849 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -545,8 +545,83 @@ export type AgentRunInfo = { started_at: string; updated_at: string; live?: boolean; + /** Modèle d'agent lancé (`deploy-verify`, `swarm`…), sinon null. */ + playbook?: string | null; }; +/** Modèle d'agent : commande, description, 3 étapes fixes, une règle (catalogue serveur). */ +export type Playbook = { + id: string; + slash: string; + name: string; + description: string; + steps: [string, string, string] | string[]; + rule: string; + visual: string; + goal_label: string; + goal_placeholder: string; + uses_workers: boolean; +}; + +export type StepState = 'pending' | 'running' | 'ok' | 'failed' | 'skipped' | 'missing'; + +/** Une ligne du journal « montre-moi ton travail » : preuve = lien ou chemin. */ +export type RunDecision = { + source: 'agent' | 'receipt' | string; + what: string; + why: string; + evidence: string; + outcome: 'ok' | 'failed' | 'skipped' | 'running' | 'unknown' | string; + step?: number | null; + agent_name?: string | null; + run_uuid?: string; + detail?: string | null; +}; + +/** Carte live d'un run (`GET /agent/runs/{run}/live`). */ +export type RunLive = { + run_uuid: string; + project_uuid: string; + status: string; + live: boolean; + verdict: 'running' | 'ok' | 'failed' | 'incomplete' | string; + status_text: string; + title?: string | null; + error?: string | null; + agent_name?: string | null; + playbook?: { id: string; slash: string; name: string; rule: string; visual: string } | null; + steps: Array<{ n: number; label: string; state: StepState }>; + workers: Array<{ + run_uuid: string; + title?: string | null; + agent_name?: string | null; + status: string; + live?: boolean; + summary?: string | null; + error?: string | null; + }>; + counters: { + tools_ok: number; + tools_failed: number; + workers_total: number; + workers_done: number; + workers_failed: number; + decisions: number; + }; + decisions: RunDecision[]; +}; + +/** Réglage « Bot Grok » : jamais la clé ni l'URL complète. */ +export type GrokBotSettings = { + configured: boolean; + url_set: boolean; + url_hint?: string | null; + key_set: boolean; + last?: { ok?: boolean | null; status?: number | null; message?: string | null; event?: string | null; at: string } | null; +}; + +export type GrokBotWakeResult = { ok: boolean; status?: number | null; message: string; event: string }; + export type Deployment = { uuid: string; status: string; @@ -1256,6 +1331,21 @@ export const api = { method: 'POST', body: '{}', }), + /** Modèles d'agents (playbooks) : catalogue, lancement en un bouton, carte live. */ + playbooks: () => request<{ data: Playbook[] }>(`/playbooks`), + runPlaybook: (projectUuid: string, playbookId: string, goal?: string) => + request<{ data: { run_uuid: string; agent_uuid: string; playbook: string; title: string } }>( + `/projects/${encodeURIComponent(projectUuid)}/playbooks/${encodeURIComponent(playbookId)}/run`, + { method: 'POST', body: JSON.stringify(goal ? { goal } : {}) }, + ), + runLive: (runUuid: string) => request<{ data: RunLive }>(`/agent/runs/${encodeURIComponent(runUuid)}/live`), + /** Bot Grok : URL de webhook + clé d'envoi (scellées, jamais renvoyées). */ + grokBotSettings: () => request<{ data: GrokBotSettings }>(`/grok-bot/settings`), + saveGrokBotSettings: (body: { url?: string; key?: string }) => + request<{ data: GrokBotSettings }>(`/grok-bot/settings`, { method: 'PATCH', body: JSON.stringify(body) }), + wakeGrokBot: (body: { project_uuid: string; run_uuid?: string; deployment_uuid?: string; message?: string }) => + request<{ data: GrokBotWakeResult }>(`/grok-bot/wake`, { method: 'POST', body: JSON.stringify(body) }), + testGrokBot: () => request<{ data: GrokBotWakeResult }>(`/grok-bot/test`, { method: 'POST', body: '{}' }), /** Rangée « En cours » : runs en file, en cours, en pause ou en échec récent (durable). */ activeAgentRuns: () => request<{ data: AgentRunInfo[] }>(`/agent/runs-active`), clearAgentMessages: (projectUuid: string, agentUuid: string) => diff --git a/apps/web/src/lib/run-live.test.ts b/apps/web/src/lib/run-live.test.ts new file mode 100644 index 0000000000..5eae0cd656 --- /dev/null +++ b/apps/web/src/lib/run-live.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import type { RunLive } from './api'; +import { counterPills, evidenceLink, needsHelp, outcomeTone, stepTone, workerState } from './run-live'; + +const counters = (c: Partial): Pick => ({ + counters: { tools_ok: 0, tools_failed: 0, workers_total: 0, workers_done: 0, workers_failed: 0, decisions: 0, ...c }, +}); + +describe('run-live', () => { + it('maps step states to tones, missing is never green', () => { + expect(stepTone('ok')).toBe('ok'); + expect(stepTone('missing')).toBe('warn'); + expect(stepTone('failed')).toBe('danger'); + expect(stepTone('pending')).toBe('muted'); + }); + + it('maps worker statuses', () => { + expect(workerState('completed')).toBe('ok'); + expect(workerState('cancelled')).toBe('failed'); + expect(workerState('queued')).toBe('pending'); + }); + + it('builds counter pills', () => { + const p = counterPills(counters({ tools_ok: 3, tools_failed: 1, workers_total: 2, workers_done: 1, decisions: 2 })); + expect(p.map((x) => x.label)).toEqual(['Workers 1/2', '3 actions OK', '1 échec', '2 décisions']); + expect(counterPills(counters({})).map((x) => x.key)).toEqual(['tools', 'decisions']); + expect(counterPills(counters({ workers_total: 2, workers_done: 1, workers_failed: 1 }))[0].tone).toBe('danger'); + }); + + it('only links http(s) or internal routes', () => { + expect(evidenceLink('https://x.fr/a')).toEqual({ href: 'https://x.fr/a', external: true }); + expect(evidenceLink('/app/receipts#r1')).toEqual({ href: '/app/receipts#r1', external: false }); + expect(evidenceLink('src/main.rs')).toBeNull(); + expect(evidenceLink('javascript:alert(1)')).toBeNull(); + }); + + it('flags runs that need help', () => { + expect(needsHelp({ verdict: 'failed' })).toBe(true); + expect(needsHelp({ verdict: 'incomplete' })).toBe(true); + expect(needsHelp({ verdict: 'ok' })).toBe(false); + expect(outcomeTone('unknown')).toBe('danger'); + }); +}); diff --git a/apps/web/src/lib/run-live.ts b/apps/web/src/lib/run-live.ts new file mode 100644 index 0000000000..8321c20a16 --- /dev/null +++ b/apps/web/src/lib/run-live.ts @@ -0,0 +1,102 @@ +import type { RunDecision, RunLive, StepState } from './api'; + +/** Rythme du sondage de la carte live (ms). */ +export const LIVE_POLL_MS = 2500; + +export const STEP_LABEL: Record = { + pending: 'À venir', + running: 'En cours', + ok: 'OK', + failed: 'Échec', + skipped: 'Ignorée', + missing: 'Sans résultat', +}; + +export type Tone = 'accent' | 'ok' | 'danger' | 'warn' | 'muted'; + +export function stepTone(state: StepState | string): Tone { + switch (state) { + case 'ok': + return 'ok'; + case 'failed': + return 'danger'; + case 'missing': + return 'warn'; + case 'running': + return 'accent'; + default: + return 'muted'; + } +} + +export function workerState(status: string): StepState { + if (status === 'completed') return 'ok'; + if (status === 'failed' || status === 'cancelled') return 'failed'; + if (status === 'running') return 'running'; + return 'pending'; +} + +export const VERDICT_LABEL: Record = { + running: 'En cours', + ok: 'Réussi', + failed: 'Échec', + incomplete: 'Incomplet', +}; + +export function verdictTone(v: string): Tone { + if (v === 'ok') return 'ok'; + if (v === 'failed') return 'danger'; + if (v === 'incomplete') return 'warn'; + return 'accent'; +} + +/** Pastilles compteurs : seules les valeurs utiles (jamais « 0 échec » inutile). */ +export function counterPills(live: Pick): Array<{ key: string; label: string; tone: Tone }> { + const c = live.counters; + const pills: Array<{ key: string; label: string; tone: Tone }> = []; + if (c.workers_total > 0) { + pills.push({ + key: 'workers', + label: `Workers ${c.workers_done}/${c.workers_total}`, + tone: c.workers_failed > 0 ? 'danger' : c.workers_done === c.workers_total ? 'ok' : 'accent', + }); + } + pills.push({ key: 'tools', label: `${c.tools_ok} action${c.tools_ok > 1 ? 's' : ''} OK`, tone: 'ok' }); + if (c.tools_failed > 0) { + pills.push({ key: 'fails', label: `${c.tools_failed} échec${c.tools_failed > 1 ? 's' : ''}`, tone: 'danger' }); + } + pills.push({ + key: 'decisions', + label: `${c.decisions} décision${c.decisions > 1 ? 's' : ''}`, + tone: c.decisions > 0 ? 'accent' : 'muted', + }); + return pills; +} + +/** Preuve cliquable : lien http(s) externe, ou route interne `/app/…`. Sinon texte (chemin). */ +export function evidenceLink(evidence: string): { href: string; external: boolean } | null { + const e = evidence.trim(); + if (/^https?:\/\/[^\s/]/i.test(e)) return { href: e, external: true }; + if (/^\/app\/[^\s]*$/.test(e)) return { href: e, external: false }; + return null; +} + +export const OUTCOME_LABEL: Record = { + ok: 'OK', + failed: 'Échec', + skipped: 'Ignorée', + running: 'En cours', + unknown: 'Issue inconnue', +}; + +export function outcomeTone(o: RunDecision['outcome']): Tone { + if (o === 'ok') return 'ok'; + if (o === 'failed' || o === 'unknown') return 'danger'; + if (o === 'running') return 'accent'; + return 'muted'; +} + +/** Bouton « Réveiller le bot » mis en avant quand le run a besoin d'aide. */ +export function needsHelp(live: Pick): boolean { + return live.verdict === 'failed' || live.verdict === 'incomplete'; +} diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 150e7f1208..101703ac28 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -67,6 +67,26 @@ pre { color: #fff; } +/* Carte live d'un run : le texte d'état glisse à chaque changement. */ +@keyframes df-status-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: none; + } +} +.df-status-in { + animation: df-status-in 0.3s ease-out; +} +@media (prefers-reduced-motion: reduce) { + .df-status-in { + animation: none; + } +} + @keyframes df-fade-up { from { opacity: 0; diff --git a/crates/agent/src/events.rs b/crates/agent/src/events.rs index a7108dc06e..20901f8392 100644 --- a/crates/agent/src/events.rs +++ b/crates/agent/src/events.rs @@ -104,6 +104,14 @@ pub fn tool_label(name: &str, args: &Value) -> String { "publish_to_github" => "Publication GitHub".into(), "create_github_repo" => "Création du dépôt GitHub".into(), "propose_plan" => "Plan".into(), + "record_decision" => { + let w = arg(args, "what"); + if w.is_empty() { + "Décision".into() + } else { + format!("Décision « {} »", clip(w, 50)) + } + } "ask_user" => "Question".into(), "create_routine" => "Routine programmée".into(), "list_routines" => "Routines".into(), diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 06752e5ebc..57e1b83a8f 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -224,6 +224,7 @@ pub fn build_core_registry( registry.register(Arc::new(ReadProjectFileTool { pool: pool.clone() })); registry.register(Arc::new(ListProjectFilesTool { pool: pool.clone() })); registry.register(Arc::new(ProposePlanTool)); + registry.register(Arc::new(tools::RecordDecisionTool)); registry.register(Arc::new(tools::AskUserTool)); registry.register(Arc::new(SyncWorkdirToGitHubTool { github: github.clone(), @@ -279,6 +280,11 @@ pub struct AgentReply { pub use events::MilestoneStatus; +/// Journal « montre-moi ton travail » : règle de preuve partagée avec le serveur. +pub mod decision { + pub use crate::tools::decision::{is_evidence, normalize_outcome, EVIDENCE_RULE}; +} + /// Événements d'un run (SSE `/agent/chat`, P2-01). Champs ajoutés avec `#[serde(default)]` : /// les anciens clients et anciens flux restent compatibles. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/agent/src/roles.rs b/crates/agent/src/roles.rs index f1b7c027d0..49f3352212 100644 --- a/crates/agent/src/roles.rs +++ b/crates/agent/src/roles.rs @@ -36,6 +36,7 @@ pub const ALL_TOOLS: &[&str] = &[ "publish_to_github", "read_github_file", "read_project_file", + "record_decision", "review_project_security", "run_application_tests", "run_workdir_command", @@ -57,6 +58,8 @@ pub const ALWAYS: &[&str] = &[ "memory_write", "memory_forget", "ask_user", + // Journal « montre-moi ton travail » : chaque rôle note ses décisions avec une preuve. + "record_decision", ]; const WORKER: &[&str] = &[ diff --git a/crates/agent/src/tools/decision.rs b/crates/agent/src/tools/decision.rs new file mode 100644 index 0000000000..d4ceefdd78 --- /dev/null +++ b/crates/agent/src/tools/decision.rs @@ -0,0 +1,182 @@ +//! Journal « montre-moi ton travail » : une ligne par décision (quoi, pourquoi, preuve, résultat). +//! L'outil ne fait que valider : la ligne est lue ensuite dans les événements du run +//! (`agent_run_events`, `tool_done` de `record_decision`). Règle : une preuve est un lien ou un +//! chemin, jamais un paragraphe. + +use async_trait::async_trait; +use devforge_shared::{Result, Tool}; +use serde_json::{json, Value}; + +pub const RECORD_DECISION: &str = "record_decision"; +pub const EVIDENCE_RULE: &str = "Une preuve est un lien ou un chemin, jamais un paragraphe."; +const MAX_EVIDENCE: usize = 300; + +/// Issue d'une décision. Absente ou inconnue : refusée (un résultat manquant n'est pas un succès). +pub fn normalize_outcome(raw: &str) -> Option<&'static str> { + match raw.trim().to_lowercase().as_str() { + "ok" | "success" | "succès" | "done" | "passed" => Some("ok"), + "failed" | "fail" | "échec" | "echec" | "error" | "ko" => Some("failed"), + "skipped" | "skip" | "ignoré" | "n/a" => Some("skipped"), + _ => None, + } +} + +/// Lien http(s) ou chemin (fichier, route) : un seul mot, sans espace, borné. +pub fn is_evidence(raw: &str) -> bool { + let s = raw.trim(); + if s.is_empty() || s.chars().count() > MAX_EVIDENCE || s.chars().any(char::is_whitespace) { + return false; + } + let lower = s.to_lowercase(); + if lower.starts_with("http://") || lower.starts_with("https://") { + let rest = s.split_once("://").map(|(_, r)| r).unwrap_or(""); + return !rest.is_empty() && !rest.starts_with('/'); + } + if lower.contains("://") || lower.starts_with("javascript:") || lower.starts_with("data:") { + return false; + } + // Chemin : `/app/…`, `./src/x.ts`, `src/main.rs`, `Cargo.toml`, `src/a.rs:42`. + s.starts_with('/') + || s.starts_with("./") + || s.starts_with("../") + || s.starts_with("~/") + || s.contains('/') + || (s.contains('.') && !s.ends_with('.')) +} + +fn arg(args: &Value, key: &str) -> String { + args.get(key) + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +fn clip(s: &str, max: usize) -> String { + let one: String = s.split_whitespace().collect::>().join(" "); + one.chars().take(max).collect() +} + +pub struct RecordDecisionTool; + +#[async_trait] +impl Tool for RecordDecisionTool { + fn name(&self) -> &str { + RECORD_DECISION + } + + fn description(&self) -> &str { + "Note une décision dans le journal « montre-moi ton travail » du run (visible par l'utilisateur).\n\ + Une ligne par décision : what (quoi, court), why (pourquoi, une phrase), evidence (preuve = \ + UN lien https://… ou UN chemin src/fichier.rs ou /app/… — jamais un paragraphe), outcome \ + (ok | failed | skipped). step (1, 2 ou 3) rattache la décision à une étape du modèle.\n\ + Un résultat non vérifié n'est pas « ok » : mets failed ou skipped." + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "what": { "type": "string", "maxLength": 120, "description": "Décision prise (court)" }, + "why": { "type": "string", "maxLength": 300, "description": "Pourquoi, en une phrase" }, + "evidence": { "type": "string", "maxLength": 300, "description": "Lien https://… ou chemin (src/x.rs, /app/…)" }, + "outcome": { "type": "string", "enum": ["ok", "failed", "skipped"] }, + "step": { "type": "integer", "minimum": 1, "maximum": 3, "description": "Étape du modèle (1-3)" } + }, + "required": ["what", "why", "evidence", "outcome"] + }) + } + + async fn execute(&self, args: Value) -> Result { + let what = clip(&arg(&args, "what"), 120); + let why = clip(&arg(&args, "why"), 300); + let evidence = arg(&args, "evidence"); + if what.is_empty() || why.is_empty() { + return Ok(json!({"ok": false, "error": "what et why sont requis"})); + } + if !is_evidence(&evidence) { + return Ok(json!({"ok": false, "error": EVIDENCE_RULE})); + } + let Some(outcome) = normalize_outcome(&arg(&args, "outcome")) else { + return Ok(json!({ + "ok": false, + "error": "outcome requis : ok, failed ou skipped (un résultat manquant ne compte jamais comme un succès)" + })); + }; + let step = args + .get("step") + .and_then(|v| { + v.as_i64() + .or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())) + }) + .filter(|n| (1..=3).contains(n)); + Ok(json!({ + "ok": true, + "decision": { + "what": what, + "why": why, + "evidence": evidence, + "outcome": outcome, + "step": step, + }, + "message": "Décision notée au journal.", + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn evidence_is_a_link_or_a_path_never_a_paragraph() { + for ok in [ + "https://web.jeser.app/health", + "src/main.rs", + "/app/receipts", + "./apps/web/src/x.tsx:42", + "Cargo.toml", + ] { + assert!(is_evidence(ok), "{ok}"); + } + for bad in [ + "", + "le build est passé sans erreur", + "javascript:alert(1)", + "ftp://x.y/z", + "ok", + &"a/".repeat(200), + ] { + assert!(!is_evidence(bad), "{bad}"); + } + } + + #[test] + fn missing_outcome_is_refused() { + assert_eq!(normalize_outcome("OK"), Some("ok")); + assert_eq!(normalize_outcome("échec"), Some("failed")); + assert_eq!(normalize_outcome(""), None); + assert_eq!(normalize_outcome("probablement"), None); + } + + #[tokio::test] + async fn records_a_valid_decision_and_refuses_paragraph_evidence() { + let t = RecordDecisionTool; + let ok = t + .execute(json!({"what": "Relancer le build", "why": "lockfile corrigé", "evidence": "package-lock.json", "outcome": "ok", "step": 2})) + .await + .unwrap(); + assert_eq!(ok["ok"], true); + assert_eq!(ok["decision"]["step"], 2); + let bad = t + .execute(json!({"what": "x", "why": "y", "evidence": "j'ai regardé les logs et tout va bien", "outcome": "ok"})) + .await + .unwrap(); + assert_eq!(bad["ok"], false); + assert_eq!(bad["error"], EVIDENCE_RULE); + let none = t + .execute(json!({"what": "x", "why": "y", "evidence": "src/a.rs"})) + .await + .unwrap(); + assert_eq!(none["ok"], false); + } +} diff --git a/crates/agent/src/tools/mod.rs b/crates/agent/src/tools/mod.rs index 1676c4398b..63b1ee7f99 100644 --- a/crates/agent/src/tools/mod.rs +++ b/crates/agent/src/tools/mod.rs @@ -1,6 +1,7 @@ mod agent_threads; pub mod ask_user; mod container_logs; +pub mod decision; mod delegate; mod deploy; mod deploy_logs; @@ -31,6 +32,7 @@ pub use container_logs::{container_logs_options, GetContainerLogsTool, GET_CONTA pub use delegate::{ set_worker_launcher, DelegateTaskTool, WorkerLauncher, DELEGATED_REFUSAL, MAX_ACTIVE_WORKERS, }; +pub use decision::RecordDecisionTool; pub use deploy::{CancelDeploymentTool, ListDeploymentsTool, TriggerDeployTool}; pub use deploy_logs::GetDeploymentLogsTool; pub use env::{ListEnvVarsTool, UpsertEnvVarsTool}; diff --git a/docs/plans/agent-playbooks.md b/docs/plans/agent-playbooks.md new file mode 100644 index 0000000000..a049e74c44 --- /dev/null +++ b/docs/plans/agent-playbooks.md @@ -0,0 +1,90 @@ +# Modèles d'agent (playbooks), carte live, journal et réveil du bot Grok + +Objectif : lancer un travail d'agent **en un bouton**, voir sa progression réelle en direct +et pouvoir réveiller un bot Grok externe quand un run ou une mise en ligne échoue. +Tout réutilise l'existant : Coordinateur du projet (`agent_runs`), workers (`delegate_task`), +événements (`agent_run_events`), reçus d'accord (`approval_receipts`), garde SSRF de page-watch, +`secret_box` pour les secrets. + +## 1. Bibliothèque de modèles + +`GET /api/v1/playbooks` — catalogue statique (`apps/server/src/playbooks.rs`) : + +| Commande | Nom | Règle | +|---|---|---| +| `/deploy-verify` | Déployer & vérifier | Un résultat manquant ne compte jamais comme un succès. | +| `/figure-it-out` | Enquêter sur un échec de mise en ligne | Une preuve est un lien ou un chemin, jamais un paragraphe. | +| `/watch-page` | Surveiller une page (URL facultative) | Un résultat manquant ne compte jamais comme un succès. | +| `/swarm` | Essaim d'agents parallèles (objectif facultatif) | Un résultat manquant ne compte jamais comme un succès. | +| `/reflect` | Retour d'expérience | Une preuve est un lien ou un chemin, jamais un paragraphe. | + +Chaque modèle : description, **3 étapes fixes**, une règle, un champ facultatif d'une ligne. + +`POST /api/v1/projects/{project}/playbooks/{id}/run` `{goal?}` → message au Coordinateur +(commande, 3 étapes, règle, consignes outils) puis démarrage immédiat du run, marqué +`agent_runs.playbook = id`. 409 si le Coordinateur travaille déjà. + +## 2. Carte live + +`GET /api/v1/agent/runs/{run}/live` — calcul pur (`compute_live`) depuis le run, ses workers +(`parent_run_uuid`), ses événements et ses reçus : + +- `status_text` : outil en cours (« Vérifie https://… »), jalon, « En file d'attente… », « 2 worker(s) en cours… », « Terminé », « Échec : … ». +- `steps[3]` : `pending | running | ok | failed | skipped | missing`. Une étape n'est `ok` **que** si l'agent l'a + notée avec une preuve (`record_decision`). Run terminé sans preuve → `missing`, verdict `incomplete`. + `/swarm` : l'étape 3 suit les workers réels. +- `workers[]`, `counters` (actions OK / en échec, workers, décisions), `decisions[]`. + +Site : tuile **Modèles** (hub Agents du projet) → modale ; carte aussi dans « En cours » pour un run de modèle. +Android : tuile **Modèles** sur la page app → feuille. Sondage toutes les 2,5 s tant que le run vit. + +## 3. « Montre-moi ton travail » + +Nouvel outil agent `record_decision {what, why, evidence, outcome: ok|failed|skipped, step?}`. +La preuve est refusée si ce n'est pas **un seul** lien http(s) ou un chemin (pas de paragraphe, pas de `javascript:`). +Le journal fusionne ces décisions et les reçus d'accord du run (preuve = `/app/receipts#`). + +## 4. Arène — limite assumée (non livrée) + +Une arène honnête (plusieurs agents, juge d'une autre famille de modèles) suppose de choisir le modèle par worker. +`delegate_task` ne le permet pas aujourd'hui : tous les workers utilisent le modèle du projet. Plutôt qu'un faux +juge, l'arène n'est pas proposée. À faire : paramètre `model` par worker + juge imposé d'un autre fournisseur. + +## 5. Réveil du bot Grok + +Réglage par espace : **Réglages → Bot Grok** (URL du webhook + clé d'envoi). + +- URL et clé sont chiffrées (`secret_box`) ; l'API ne renvoie jamais la clé ni l'URL complète (seulement `https://hôte/…`). +- URL : `https://` publique uniquement ; la résolution DNS passe par la même garde que page-watch (adresses + privées, loopback, link-local, métadonnées cloud refusées), redirections **non suivies**, pas de proxy. +- Envoi : un seul essai, 8 s max, corps de réponse jamais relayé. Au plus un réveil toutes les 5 s par espace. + +Boutons « Réveiller le bot Grok » : carte live, run en échec dans « En cours », mise en ligne en échec +(Déploiements). Bouton « Tester » dans le réglage. Le résultat (HTTP, message) s'affiche sous le bouton. + +`POST /api/v1/grok-bot/wake` `{project_uuid, run_uuid?, deployment_uuid?, message?}` envoie : + +```http +POST +Content-Type: application/json +Authorization: Bearer +X-Automation-Key: + +{ + "event": "devforge.run_failed", // devforge.run_failed | devforge.deploy_failed | devforge.wake | devforge.test + "project": { "uuid": "…", "name": "…", "url": "https://…" }, + "run": { "type": "agent_run", "uuid": "…", "status": "failed", "title": "/deploy-verify · Déployer & vérifier", "error": "…", "summary": null }, + "message": "Texte court lisible par le bot (2000 caractères max)" +} +``` + +Pour une mise en ligne, `run` vaut `{"type": "deployment", "uuid", "status", "title" (message du commit), "error"}` ; +`null` pour un réveil simple ou un test (`project` vaut aussi `null` pour `devforge.test`). +Réponse 2xx = réveil accepté ; 401/403 = clé refusée ; 3xx = redirection refusée. + +## Tests + +- Rust : catalogue, prompt, états d'étapes (manquant ≠ succès), essaim, statut, journal, preuve, refus SSRF + (127.0.0.1, 10.x, 169.254.169.254, http://), en-têtes de clé, redirection non suivie, clé jamais renvoyée. +- Web : `lib/run-live.test.ts`, `components/RunLiveCard.test.tsx`. +- Android : `PlaybooksTest` (parsing, pastilles, preuve).