Page /commander (onglets, durée max, coût max estimé live, effort inspection dégradés) + garde-fou coût runner + commits conservés si plafond atteint
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7 changed files +340 −109
modified
orchestrator/main.py
+72 −9
@@ -273,6 +273,37 @@ def build_effort_prompt(kind: str, service: str, source: str, note: str) -> str: | ||
| 273 | 273 | Tu es sur le nœud {node_alias}, répertoire courant = repo de l'app: {svc['dir']} (remote-first, source de vérité). |
| 274 | 274 | L'app tourne via pm2 ({', '.join(svc['pm2'])}); process de sync: {sync_proc}; site local port {svc['web_port']}. |
| 275 | 275 | Commence par lire le CLAUDE.md / README du repo et docs/connecteurs/ s'ils existent.""" |
| 276 | + if kind == "effort_degrade": | |
| 277 | + block = LATEST["mine"].get(service) or {} | |
| 278 | + degraded = [c for c in block.get("connectors", []) if c.get("status") == "degraded"][:25] | |
| 279 | + deg_lines = "\n".join( | |
| 280 | + f"- {c['source']}: dernier volume {c.get('found_last')} vs médiane {round(c.get('median_found') or 0)} " | |
| 281 | + f"(dernier succès: {c.get('last_success')}, message: {c.get('message')})" | |
| 282 | + for c in degraded) or "- (aucun connecteur dégradé au dernier scan — re-vérifie l'état réel dans l'app)" | |
| 283 | + return f"""Tu es {AGENT}, agent gardien autonome du Groupe KA. EFFORT COMMANDÉ: INSPECTER LES CONNECTEURS DÉGRADÉS de l'app {svc['app']} (service {service}, site {svc.get('site', '')}). | |
| 284 | +{"Consigne de l'opérateur: " + note if note else ""} | |
| 285 | + | |
| 286 | +Un connecteur « dégradé » livre encore des données, mais moins de 50 % de sa médiane historique — souvent le signe d'une pagination cassée, d'un filtre qui se resserre, d'une section du site source disparue ou d'un blocage partiel. | |
| 287 | + | |
| 288 | +== CONNECTEURS DÉGRADÉS AU DERNIER SCAN api-ka == | |
| 289 | +{deg_lines} | |
| 290 | + | |
| 291 | +{env} | |
| 292 | + | |
| 293 | +== DÉMARCHE == | |
| 294 | +1. TRIE: pour chaque connecteur dégradé, regarde vite (logs + un fetch de contrôle) si la baisse est (a) réelle et réparable, (b) légitime (le site source a vraiment moins d'items — saison, inventaire réduit), ou (c) un blocage. | |
| 295 | +2. PRIORISE les cas (a) au plus fort potentiel de volume récupéré, et répare-les UN PAR UN: cause racine, correctif minimal, test réel avec volume mesuré avant/après. Commit séparé par connecteur réparé ([{AGENT}] fix connecteur <source>: …). | |
| 296 | +3. Pour les cas (b), ne touche à rien: consigne-les dans ton rapport final comme « baisse légitime ». | |
| 297 | +4. Traite autant de connecteurs que ton budget de temps le permet, en gardant la qualité: mieux vaut 3 vraies réparations prouvées que 10 rustines. | |
| 298 | +5. À la fin: `pm2 restart {sync_proc}`, vérifie le site (port {svc['web_port']}). | |
| 299 | + | |
| 300 | +{effort_stack(service)} | |
| 301 | + | |
| 302 | +{rules} | |
| 303 | + | |
| 304 | +== FIN DE MISSION == | |
| 305 | +Termine ta TOUTE DERNIÈRE réponse par un bloc JSON: | |
| 306 | +{{"verdict": "livre|echec", "diagnostic": "portrait global des dégradés", "actions": "connecteurs réparés (avec volumes avant/après) / baisses légitimes / blocages", "test": "mesures", "fichiers": ["fichiers modifiés"]}}""" | |
| 276 | 307 | if kind == "effort_new": |
| 277 | 308 | return f"""Tu es {AGENT}, agent gardien autonome du Groupe KA. EFFORT COMMANDÉ: ajouter UN NOUVEAU CONNECTEUR de qualité production à l'app {svc['app']} (service {service}, site {svc.get('site', '')}). |
| 278 | 309 | {"Consigne de l'opérateur: " + note if note else "Aucune consigne particulière: choisis la source la plus utile."} |
@@ -324,9 +355,16 @@ async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None: | ||
| 324 | 355 | mid = uuid.uuid4().hex[:12] |
| 325 | 356 | my_ip = os.environ.get("KA_GUARDIAN_SELF_IP", "192.168.2.69") |
| 326 | 357 | kind = incident["status_detected"] |
| 327 | − if kind in ("effort_new", "effort_enrich"): | |
| 358 | + max_cost = None | |
| 359 | + if kind in ("effort_new", "effort_enrich", "effort_degrade"): | |
| 328 | 360 | prompt = build_effort_prompt(kind, service, source, health.get("note", "")) |
| 329 | − max_turns, timeout = POLICY.get("effort_max_turns", 150), POLICY.get("effort_timeout_seconds", 7200) | |
| 361 | + max_turns = POLICY.get("effort_max_turns", 150) | |
| 362 | + timeout = POLICY.get("effort_timeout_seconds", 7200) | |
| 363 | + # Plafonds choisis par l'opérateur au moment de commander l'effort. | |
| 364 | + if health.get("max_minutes"): | |
| 365 | + timeout = min(timeout, int(health["max_minutes"]) * 60) | |
| 366 | + if health.get("max_cost_usd"): | |
| 367 | + max_cost = float(health["max_cost_usd"]) | |
| 330 | 368 | else: |
| 331 | 369 | prompt = build_prompt(service, source, health) |
| 332 | 370 | max_turns, timeout = POLICY["mission_max_turns"], POLICY["mission_timeout_seconds"] |
@@ -336,6 +374,7 @@ async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None: | ||
| 336 | 374 | "model": ME.get("model", "sonnet"), |
| 337 | 375 | "max_turns": max_turns, |
| 338 | 376 | "timeout_seconds": timeout, |
| 377 | + "max_cost_usd": max_cost, | |
| 339 | 378 | "prompt": prompt, |
| 340 | 379 | "callback_url": f"http://{my_ip}:{ME['port']}/api/ingest/{mid}", |
| 341 | 380 | } |
@@ -581,7 +620,7 @@ async def finalize(c: sqlite3.Connection, m: sqlite3.Row, etype: str, data: dict | ||
| 581 | 620 | v = verdict.get("verdict", "inconnu") |
| 582 | 621 | if not inc: |
| 583 | 622 | return |
| 584 | − if inc["status_detected"] in ("effort_new", "effort_enrich"): | |
| 623 | + if inc["status_detected"] in ("effort_new", "effort_enrich", "effort_degrade"): | |
| 585 | 624 | # Les efforts commandés ne passent pas par la surveillance api-ka: |
| 586 | 625 | # verdict + santé de l'app décident tout de suite. |
| 587 | 626 | if not health.get("ok", True): |
@@ -592,11 +631,16 @@ async def finalize(c: sqlite3.Connection, m: sqlite3.Row, etype: str, data: dict | ||
| 592 | 631 | set_incident(c, inc["id"], state="resolved", resolved=now()) |
| 593 | 632 | incident_event(inc["id"], m["service"], m["source"], "resolved", |
| 594 | 633 | f"effort livré ({len(commits)} commit{'s' if len(commits) > 1 else ''})") |
| 634 | + elif v == "echec" and commits: | |
| 635 | + asyncio.create_task(rollback_mission(m2, "effort en échec déclaré → rollback préventif")) | |
| 636 | + set_incident(c, inc["id"], state="abandoned") | |
| 637 | + incident_event(inc["id"], m["service"], m["source"], "abandoned", "effort en échec → rollback") | |
| 595 | 638 | else: |
| 596 | − if commits: | |
| 597 | − asyncio.create_task(rollback_mission(m2, f"effort verdict {v} → rollback préventif")) | |
| 639 | + # Session interrompue (plafond durée/coût) ou verdict illisible: | |
| 640 | + # l'app est saine, les commits incrémentaux testés sont conservés. | |
| 598 | 641 | set_incident(c, inc["id"], state="abandoned") |
| 599 | − incident_event(inc["id"], m["service"], m["source"], "abandoned", f"effort en échec ({v})") | |
| 642 | + incident_event(inc["id"], m["service"], m["source"], "abandoned", | |
| 643 | + f"effort terminé sans verdict livré ({v}) — {len(commits)} commit(s) conservé(s), app saine") | |
| 600 | 644 | return |
| 601 | 645 | if not health.get("ok", True): |
| 602 | 646 | # L'app est tombée → rollback immédiat, quoi qu'ait dit l'agent. |
@@ -702,21 +746,35 @@ async def admin_effort(req: Request, x_ka_token: str | None = Header(default=Non | ||
| 702 | 746 | kind = body.get("kind") |
| 703 | 747 | service = body.get("service") |
| 704 | 748 | note = (body.get("note") or "").strip()[:2000] |
| 705 | − if kind not in ("effort_new", "effort_enrich") or service not in SERVICES: | |
| 749 | + if kind not in ("effort_new", "effort_enrich", "effort_degrade") or service not in SERVICES: | |
| 706 | 750 | raise HTTPException(status_code=400, detail="kind ou service invalide") |
| 707 | 751 | if kind == "effort_enrich": |
| 708 | 752 | source = (body.get("source") or "").strip() |
| 709 | 753 | if not source: |
| 710 | 754 | raise HTTPException(status_code=400, detail="source requise pour un enrichissement") |
| 755 | + elif kind == "effort_degrade": | |
| 756 | + source = f"dégradés·{uuid.uuid4().hex[:6]}" | |
| 711 | 757 | else: |
| 712 | 758 | source = f"nouveau·{uuid.uuid4().hex[:6]}" |
| 759 | + detail: dict[str, Any] = {"note": note, "kind": kind} | |
| 760 | + try: | |
| 761 | + if body.get("max_minutes"): | |
| 762 | + detail["max_minutes"] = max(10, min(240, int(body["max_minutes"]))) | |
| 763 | + if body.get("max_cost_usd"): | |
| 764 | + detail["max_cost_usd"] = max(0.5, min(100.0, float(body["max_cost_usd"]))) | |
| 765 | + except (TypeError, ValueError): | |
| 766 | + raise HTTPException(status_code=400, detail="max_minutes/max_cost_usd invalides") | |
| 713 | 767 | iid = uuid.uuid4().hex[:10] |
| 714 | 768 | with db() as c: |
| 715 | 769 | c.execute("INSERT INTO incidents(id,service,source,status_detected,state,created,updated,detail) " |
| 716 | 770 | "VALUES(?,?,?,?,?,?,?,?)", |
| 717 | − (iid, service, source, kind, "open", now(), now(), jdump({"note": note, "kind": kind}))) | |
| 771 | + (iid, service, source, kind, "open", now(), now(), jdump(detail))) | |
| 772 | + labels = {"effort_new": "nouveau connecteur", "effort_enrich": f"enrichissement de {source}", | |
| 773 | + "effort_degrade": "inspection des connecteurs dégradés"} | |
| 718 | 774 | incident_event(iid, service, source, "open", |
| 719 | − "effort commandé: " + ("nouveau connecteur" if kind == "effort_new" else f"enrichissement de {source}")) | |
| 775 | + f"effort commandé: {labels[kind]}" | |
| 776 | + + (f" · ≤{detail['max_minutes']} min" if detail.get("max_minutes") else "") | |
| 777 | + + (f" · ≤{detail['max_cost_usd']} $" if detail.get("max_cost_usd") else "")) | |
| 720 | 778 | return {"ok": True, "incident_id": iid} |
| 721 | 779 | |
| 722 | 780 | |
@@ -769,6 +827,11 @@ def index() -> FileResponse: | ||
| 769 | 827 | return FileResponse(BASE / "web" / "index.html") |
| 770 | 828 | |
| 771 | 829 | |
| 830 | +@app.get("/commander") | |
| 831 | +def commander() -> FileResponse: | |
| 832 | + return FileResponse(BASE / "web" / "commander.html") | |
| 833 | + | |
| 834 | + | |
| 772 | 835 | app.mount("/static", StaticFiles(directory=BASE / "web"), name="static") |
| 773 | 836 | |
| 774 | 837 | |
modified
orchestrator/web/app.js
+3 −59
@@ -7,7 +7,8 @@ const STATE_FR = { open: "à réparer", dispatched: "envoi…", fixing: "répara | ||
| 7 | 7 | cooldown: "attente", resolved: "résolu", self_healed: "auto-guéri", abandoned: "abandonné" }; |
| 8 | 8 | const STATUSES = ["ok", "degraded", "broken", "stale"]; |
| 9 | 9 | const STATUS_FR = { ok: "ok", degraded: "dégradé", broken: "cassé", stale: "endormi", manual: "manuel", |
| 10 | − effort_new: "effort · nouveau connecteur", effort_enrich: "effort · enrichissement" }; | |
| 10 | + effort_new: "effort · nouveau connecteur", effort_enrich: "effort · enrichissement", | |
| 11 | + effort_degrade: "effort · inspection dégradés" }; | |
| 11 | 12 | const VERDICT_FR = { repare: "réparé", livre: "livré", echec: "échec", site_source_mort: "source morte", |
| 12 | 13 | rien_a_faire: "rien à faire", inconnu: "inconnu", erreur: "erreur" }; |
| 13 | 14 | |
@@ -35,64 +36,7 @@ async function load() { | ||
| 35 | 36 | $("#foot-sites").innerHTML = |
| 36 | 37 | Object.values(STATE.services).map((s) => `<a href="${s.site}">${esc(s.app)}</a>`).join("") + |
| 37 | 38 | Object.entries(STATE.siblings).map(([a, v]) => `<a href="https://${v.domain}">${a}·guardian</a>`).join(""); |
| 38 | − renderTiles(); renderIncidents(); renderCoverage(); renderMissions(); initEffortForm(); | |
| 39 | −} | |
| 40 | − | |
| 41 | −/* ---- commander un effort ---- */ | |
| 42 | −let effortInit = false; | |
| 43 | −function initEffortForm() { | |
| 44 | − const svcSel = $("#ef-service"); | |
| 45 | − if (!effortInit) { | |
| 46 | − svcSel.innerHTML = Object.entries(STATE.services) | |
| 47 | − .map(([k, s]) => `<option value="${k}">${esc(s.app)} — ${esc(s.node_alias)}</option>`).join(""); | |
| 48 | − $("#ef-token").value = localStorage.getItem("ka_token") || ""; | |
| 49 | − $("#ef-kind").addEventListener("change", syncEffortKind); | |
| 50 | − svcSel.addEventListener("change", fillSources); | |
| 51 | − $("#effort-form").addEventListener("submit", submitEffort); | |
| 52 | − effortInit = true; | |
| 53 | − } | |
| 54 | −} | |
| 55 | −function syncEffortKind() { | |
| 56 | − const enrich = $("#ef-kind").value === "effort_enrich"; | |
| 57 | − $("#ef-source-wrap").hidden = !enrich; | |
| 58 | − if (enrich) fillSources(); | |
| 59 | −} | |
| 60 | −async function fillSources() { | |
| 61 | − if ($("#ef-kind").value !== "effort_enrich") return; | |
| 62 | − const r = await fetch("/api/connectors/" + $("#ef-service").value); | |
| 63 | − const d = await r.json(); | |
| 64 | − $("#ef-source").innerHTML = d.connectors | |
| 65 | − .map((c) => `<option value="${esc(c.source)}">${esc(c.source)} (${STATUS_FR[c.status] || c.status})</option>`).join("") | |
| 66 | − || `<option value="">— aucun connecteur connu —</option>`; | |
| 67 | −} | |
| 68 | −async function submitEffort(e) { | |
| 69 | − e.preventDefault(); | |
| 70 | − const msg = $("#ef-msg"), btn = e.target.querySelector("button"); | |
| 71 | − const token = $("#ef-token").value.trim(); | |
| 72 | − if (!token) { msg.className = "cmd-msg err"; msg.textContent = "jeton d'opérateur requis"; return; } | |
| 73 | − localStorage.setItem("ka_token", token); | |
| 74 | − btn.disabled = true; msg.className = "cmd-msg"; msg.textContent = "lancement…"; | |
| 75 | − try { | |
| 76 | − const r = await fetch("/api/admin/effort", { | |
| 77 | − method: "POST", headers: { "Content-Type": "application/json", "X-KA-Token": token }, | |
| 78 | − body: JSON.stringify({ | |
| 79 | − kind: $("#ef-kind").value, service: $("#ef-service").value, | |
| 80 | − source: $("#ef-source") ? $("#ef-source").value : "", note: $("#ef-note").value, | |
| 81 | − }), | |
| 82 | − }); | |
| 83 | − const d = await r.json(); | |
| 84 | − if (r.ok && d.ok) { | |
| 85 | − msg.className = "cmd-msg ok"; | |
| 86 | − msg.textContent = `effort accepté (incident ${d.incident_id}) — la session démarre d'ici ~5 min, suis le flux ⚡`; | |
| 87 | − $("#ef-note").value = ""; | |
| 88 | − refetch(); | |
| 89 | − } else { | |
| 90 | − msg.className = "cmd-msg err"; | |
| 91 | − msg.textContent = "refusé: " + (d.detail || r.status); | |
| 92 | − } | |
| 93 | − } catch (err) { | |
| 94 | − msg.className = "cmd-msg err"; msg.textContent = "erreur: " + err; | |
| 95 | − } finally { btn.disabled = false; } | |
| 39 | + renderTiles(); renderIncidents(); renderCoverage(); renderMissions(); | |
| 96 | 40 | } |
| 97 | 41 | |
| 98 | 42 | function renderTiles() { |
added
orchestrator/web/commander.html
+109 −0
@@ -0,0 +1,109 @@ | ||
| 1 | +<!doctype html> | |
| 2 | +<html lang="fr-CA"> | |
| 3 | +<head> | |
| 4 | +<meta charset="utf-8"> | |
| 5 | +<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> | |
| 6 | +<title>Commander — KA Guardian</title> | |
| 7 | +<meta name="description" content="Poste de commande de l'agent gardien : lancer une session Claude Code 100 % autonome — nouveau connecteur, enrichissement ou inspection des connecteurs dégradés — avec plafonds de durée et de coût."> | |
| 8 | +<link rel="icon" type="image/svg+xml" href="/favicon.svg"> | |
| 9 | +<link rel="preconnect" href="https://fonts.googleapis.com"> | |
| 10 | +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| 11 | +<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet"> | |
| 12 | +<link rel="stylesheet" href="/static/style.css"> | |
| 13 | +</head> | |
| 14 | +<body> | |
| 15 | + | |
| 16 | +<header class="topbar"> | |
| 17 | + <a class="wordmark" href="/"><span id="wm-name">ka·—</span><span class="ka">guardian</span></a> | |
| 18 | + <nav class="tabs"> | |
| 19 | + <a class="tab" href="/">⚡ Flux</a> | |
| 20 | + <a class="tab active" href="/commander">🎛 Commander</a> | |
| 21 | + </nav> | |
| 22 | + <nav class="topnav"> | |
| 23 | + <a class="gk-badge" href="https://www.groupe-ka.com">groupe<b><span class="ka">·KA</span></b></a> | |
| 24 | + <span id="siblings"></span> | |
| 25 | + </nav> | |
| 26 | +</header> | |
| 27 | + | |
| 28 | +<main class="cmd-page"> | |
| 29 | + <header class="cmd-hero"> | |
| 30 | + <p class="klabel" id="hero-label">poste de commande</p> | |
| 31 | + <h1>Donne-lui du <span class="hl">travail</span>.</h1> | |
| 32 | + <p class="lede">Une session Claude Code <b>100 % autonome</b> démarre sur le nœud de la | |
| 33 | + plateforme choisie : elle découvre (Serper), escalade s'il le faut (proxys résidentiels, | |
| 34 | + Scrapfly, acteurs Apify), teste pour vrai, committe — et travaille jusqu'au bout sans rien | |
| 35 | + demander. Tu fixes la laisse : durée max et budget max. Tout s'affiche dans | |
| 36 | + <a href="/" style="font-weight:700; text-decoration:underline; text-underline-offset:4px">le flux ⚡</a>.</p> | |
| 37 | + </header> | |
| 38 | + | |
| 39 | + <section class="commander card"> | |
| 40 | + <form id="effort-form" class="cmd-form"> | |
| 41 | + <div class="frow frow-3"> | |
| 42 | + <label class="flab">Type d'effort | |
| 43 | + <select id="ef-kind" class="select"> | |
| 44 | + <option value="effort_new">➕ Nouveau connecteur — découverte + construction</option> | |
| 45 | + <option value="effort_enrich">⤴ Enrichir un connecteur existant</option> | |
| 46 | + <option value="effort_degrade">🩺 Inspecter les connecteurs dégradés</option> | |
| 47 | + </select> | |
| 48 | + </label> | |
| 49 | + <label class="flab">Plateforme | |
| 50 | + <select id="ef-service" class="select"></select> | |
| 51 | + </label> | |
| 52 | + <label class="flab" id="ef-source-wrap" hidden>Connecteur à enrichir | |
| 53 | + <select id="ef-source" class="select"></select> | |
| 54 | + </label> | |
| 55 | + </div> | |
| 56 | + <p class="kind-hint" id="kind-hint"></p> | |
| 57 | + <label class="flab">Consigne (optionnel) | |
| 58 | + <input id="ef-note" class="input" placeholder="ex.: vise les microbrasseries de la Côte-Nord, ajoute les fiches détail…"> | |
| 59 | + </label> | |
| 60 | + <div class="frow frow-3"> | |
| 61 | + <label class="flab">Durée max | |
| 62 | + <select id="ef-minutes" class="select"> | |
| 63 | + <option value="30">30 minutes</option> | |
| 64 | + <option value="60" selected>1 heure</option> | |
| 65 | + <option value="120">2 heures</option> | |
| 66 | + <option value="240">4 heures</option> | |
| 67 | + </select> | |
| 68 | + </label> | |
| 69 | + <label class="flab">Coût max (API, estimé en direct) | |
| 70 | + <select id="ef-cost" class="select"> | |
| 71 | + <option value="2">2 $</option> | |
| 72 | + <option value="5" selected>5 $</option> | |
| 73 | + <option value="10">10 $</option> | |
| 74 | + <option value="25">25 $</option> | |
| 75 | + <option value="50">50 $</option> | |
| 76 | + </select> | |
| 77 | + </label> | |
| 78 | + <label class="flab">Jeton d'opérateur | |
| 79 | + <input id="ef-token" type="password" class="input" placeholder="KA_GUARDIAN_TOKEN" autocomplete="off"> | |
| 80 | + </label> | |
| 81 | + </div> | |
| 82 | + <div class="frow frow-end"> | |
| 83 | + <p class="cmd-note">Plafond atteint → la session s'arrête proprement ; les réparations | |
| 84 | + déjà committées et testées sont conservées si la plateforme est saine.</p> | |
| 85 | + <button type="submit" class="btn-primary">Lancer l'effort →</button> | |
| 86 | + </div> | |
| 87 | + <p class="cmd-msg" id="ef-msg"></p> | |
| 88 | + </form> | |
| 89 | + </section> | |
| 90 | + | |
| 91 | + <section> | |
| 92 | + <p class="klabel">efforts commandés récents</p> | |
| 93 | + <div id="efforts-list" class="incidents" style="max-height:none"></div> | |
| 94 | + </section> | |
| 95 | +</main> | |
| 96 | + | |
| 97 | +<footer class="ka-footer"> | |
| 98 | + <div class="foot-in"> | |
| 99 | + <a class="wordmark wordmark-foot" href="/"><span id="foot-name">ka·—</span><span class="ka">guardian</span></a> | |
| 100 | + <p class="notice"><b>La laisse est réelle.</b> Chaque effort est plafonné en durée et en coût, | |
| 101 | + journalisé action par action, et annulable par rollback git. L'agent travaille seul ; | |
| 102 | + il ne décide jamais seul de ce qui compte.</p> | |
| 103 | + <p class="legal">Groupe KA — agrégation automatisée, Québec. <a href="https://www.groupe-ka.com">groupe-ka.com</a></p> | |
| 104 | + </div> | |
| 105 | +</footer> | |
| 106 | + | |
| 107 | +<script src="/static/commander.js"></script> | |
| 108 | +</body> | |
| 109 | +</html> | |
added
orchestrator/web/commander.js
+101 −0
@@ -0,0 +1,101 @@ | ||
| 1 | +/* KA Guardian — poste de commande (/commander) */ | |
| 2 | +const $ = (s) => document.querySelector(s); | |
| 3 | +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); | |
| 4 | +const STATUS_FR = { ok: "ok", degraded: "dégradé", broken: "cassé", stale: "endormi" }; | |
| 5 | +const STATE_FR = { open: "en file", dispatched: "envoi…", fixing: "en cours", watching: "surveillance", | |
| 6 | + cooldown: "attente", resolved: "livré", self_healed: "auto-guéri", abandoned: "terminé" }; | |
| 7 | +const KIND_FR = { effort_new: "nouveau connecteur", effort_enrich: "enrichissement", effort_degrade: "inspection dégradés" }; | |
| 8 | +const HINTS = { | |
| 9 | + effort_new: "L'agent cartographie l'existant, découvre des sources québécoises non couvertes (Serper), choisit la meilleure et construit le connecteur complet — testé en volume réel.", | |
| 10 | + effort_enrich: "L'agent maximise la valeur du connecteur choisi : pagination complète, champs des fiches détail, robustesse, détection des retraits — mesuré avant/après.", | |
| 11 | + effort_degrade: "L'agent inspecte tous les connecteurs dégradés de la plateforme (volume < 50 % de la médiane), trie baisse réelle / légitime / blocage, et répare un par un — un commit par connecteur.", | |
| 12 | +}; | |
| 13 | + | |
| 14 | +let STATE = null; | |
| 15 | + | |
| 16 | +async function load() { | |
| 17 | + const r = await fetch("/api/state"); | |
| 18 | + STATE = await r.json(); | |
| 19 | + const id = STATE.identity, num = STATE.agent.replace("ka", ""); | |
| 20 | + const rs = document.documentElement.style; | |
| 21 | + rs.setProperty("--agent", id.accent); rs.setProperty("--lime", id.accent); | |
| 22 | + if (id.accent_soft) rs.setProperty("--lime-soft", id.accent_soft); | |
| 23 | + if (id.accent2) rs.setProperty("--accent-deep", id.accent2); | |
| 24 | + document.title = `Commander — ${STATE.agent.toUpperCase()} Guardian`; | |
| 25 | + $("#wm-name").textContent = "ka·" + num; | |
| 26 | + $("#foot-name").textContent = "ka·" + num; | |
| 27 | + $("#hero-label").textContent = `${STATE.agent} — poste de commande · ${id.tagline}`; | |
| 28 | + $("#siblings").innerHTML = Object.entries(STATE.siblings).map(([a, v]) => | |
| 29 | + `<a class="gk-badge" href="https://${v.domain}/commander"><span class="dot" style="background:${v.accent}"></span><b>${a}<span class="ka">·G</span></b></a>`).join(" "); | |
| 30 | + const svcSel = $("#ef-service"); | |
| 31 | + if (!svcSel.options.length) { | |
| 32 | + svcSel.innerHTML = Object.entries(STATE.services) | |
| 33 | + .map(([k, s]) => `<option value="${k}">${esc(s.app)} — ${esc(s.node_alias)}</option>`).join(""); | |
| 34 | + } | |
| 35 | + renderEfforts(); | |
| 36 | +} | |
| 37 | + | |
| 38 | +function renderEfforts() { | |
| 39 | + const efforts = STATE.incidents.filter((i) => (i.status_detected || "").startsWith("effort")); | |
| 40 | + $("#efforts-list").innerHTML = efforts.slice(0, 20).map((i) => { | |
| 41 | + const d = JSON.parse(i.detail || "{}"); | |
| 42 | + return `<div class="inc"> | |
| 43 | + <div><div class="src">${esc(KIND_FR[i.status_detected] || i.status_detected)} — ${esc((STATE.services[i.service] || {}).app || i.service)}</div> | |
| 44 | + <div class="svc">${esc(i.source)}${d.note ? " · « " + esc(d.note.slice(0, 80)) + " »" : ""}${d.max_minutes ? " · ≤" + d.max_minutes + " min" : ""}${d.max_cost_usd ? " · ≤" + d.max_cost_usd + " $" : ""}</div></div> | |
| 45 | + <span class="badge b-${esc(i.state)}">${STATE_FR[i.state] || esc(i.state)}</span></div>`; | |
| 46 | + }).join("") || `<div class="empty">Aucun effort commandé encore — sois le premier à lui donner du travail.</div>`; | |
| 47 | +} | |
| 48 | + | |
| 49 | +function syncKind() { | |
| 50 | + const kind = $("#ef-kind").value; | |
| 51 | + $("#ef-source-wrap").hidden = kind !== "effort_enrich"; | |
| 52 | + $("#kind-hint").textContent = HINTS[kind] || ""; | |
| 53 | + if (kind === "effort_enrich") fillSources(); | |
| 54 | +} | |
| 55 | + | |
| 56 | +async function fillSources() { | |
| 57 | + const r = await fetch("/api/connectors/" + $("#ef-service").value); | |
| 58 | + const d = await r.json(); | |
| 59 | + $("#ef-source").innerHTML = d.connectors | |
| 60 | + .map((c) => `<option value="${esc(c.source)}">${esc(c.source)} (${STATUS_FR[c.status] || c.status})</option>`).join("") | |
| 61 | + || `<option value="">— aucun connecteur connu —</option>`; | |
| 62 | +} | |
| 63 | + | |
| 64 | +async function submitEffort(e) { | |
| 65 | + e.preventDefault(); | |
| 66 | + const msg = $("#ef-msg"), btn = e.target.querySelector("button"); | |
| 67 | + const token = $("#ef-token").value.trim(); | |
| 68 | + if (!token) { msg.className = "cmd-msg err"; msg.textContent = "jeton d'opérateur requis"; return; } | |
| 69 | + localStorage.setItem("ka_token", token); | |
| 70 | + btn.disabled = true; msg.className = "cmd-msg"; msg.textContent = "lancement…"; | |
| 71 | + try { | |
| 72 | + const r = await fetch("/api/admin/effort", { | |
| 73 | + method: "POST", headers: { "Content-Type": "application/json", "X-KA-Token": token }, | |
| 74 | + body: JSON.stringify({ | |
| 75 | + kind: $("#ef-kind").value, service: $("#ef-service").value, | |
| 76 | + source: $("#ef-source").value || "", note: $("#ef-note").value, | |
| 77 | + max_minutes: parseInt($("#ef-minutes").value, 10), | |
| 78 | + max_cost_usd: parseFloat($("#ef-cost").value), | |
| 79 | + }), | |
| 80 | + }); | |
| 81 | + const d = await r.json(); | |
| 82 | + if (r.ok && d.ok) { | |
| 83 | + msg.className = "cmd-msg ok"; | |
| 84 | + msg.textContent = `effort accepté (${d.incident_id}) — la session démarre d'ici ~5 min. Suis-la sur le flux ⚡`; | |
| 85 | + $("#ef-note").value = ""; | |
| 86 | + load(); | |
| 87 | + } else { | |
| 88 | + msg.className = "cmd-msg err"; | |
| 89 | + msg.textContent = "refusé: " + (d.detail || r.status); | |
| 90 | + } | |
| 91 | + } catch (err) { | |
| 92 | + msg.className = "cmd-msg err"; msg.textContent = "erreur: " + err; | |
| 93 | + } finally { btn.disabled = false; } | |
| 94 | +} | |
| 95 | + | |
| 96 | +$("#ef-kind").addEventListener("change", syncKind); | |
| 97 | +$("#ef-service").addEventListener("change", () => { if ($("#ef-kind").value === "effort_enrich") fillSources(); }); | |
| 98 | +$("#effort-form").addEventListener("submit", submitEffort); | |
| 99 | +$("#ef-token").value = localStorage.getItem("ka_token") || ""; | |
| 100 | +load().then(syncKind); | |
| 101 | +setInterval(load, 30000); | |
modified
orchestrator/web/index.html
+4 −40
@@ -16,6 +16,10 @@ | ||
| 16 | 16 | |
| 17 | 17 | <header class="topbar"> |
| 18 | 18 | <a class="wordmark" href="/"><span id="wm-name">ka·—</span><span class="ka" id="wm-ka">guardian</span></a> |
| 19 | + <nav class="tabs"> | |
| 20 | + <a class="tab active" href="/">⚡ Flux</a> | |
| 21 | + <a class="tab" href="/commander">🎛 Commander</a> | |
| 22 | + </nav> | |
| 19 | 23 | <nav class="topnav"> |
| 20 | 24 | <a class="gk-badge" href="https://www.groupe-ka.com">groupe<b><span class="ka">·KA</span></b></a> |
| 21 | 25 | <span id="siblings"></span> |
@@ -53,46 +57,6 @@ | ||
| 53 | 57 | <div class="tiles" id="tiles"></div> |
| 54 | 58 | </section> |
| 55 | 59 | |
| 56 | − <!-- ============ COMMANDER UN EFFORT ============ --> | |
| 57 | − <section class="commander card"> | |
| 58 | − <div class="cmd-grid"> | |
| 59 | − <div class="cmd-intro"> | |
| 60 | − <p class="klabel">commander un effort</p> | |
| 61 | − <h2 class="cmd-title">Donne-lui du <span class="hl">travail</span>.</h2> | |
| 62 | − <p class="cmd-lede">Choisis une plateforme et lance une session Claude Code | |
| 63 | − <b>100 % autonome</b> sur son nœud : elle découvre (Serper), escalade s'il le faut | |
| 64 | − (proxys résidentiels, Scrapfly, acteurs Apify), teste pour vrai, committe — | |
| 65 | − et travaille jusqu'au bout sans rien demander. Tout s'affiche dans le flux.</p> | |
| 66 | − </div> | |
| 67 | − <form id="effort-form"> | |
| 68 | − <div class="frow"> | |
| 69 | − <label class="flab">Type d'effort | |
| 70 | − <select id="ef-kind" class="select"> | |
| 71 | − <option value="effort_new">Nouveau connecteur — découverte + construction</option> | |
| 72 | − <option value="effort_enrich">Enrichissement d'un connecteur existant</option> | |
| 73 | − </select> | |
| 74 | − </label> | |
| 75 | − <label class="flab">Plateforme | |
| 76 | − <select id="ef-service" class="select"></select> | |
| 77 | − </label> | |
| 78 | − </div> | |
| 79 | − <label class="flab" id="ef-source-wrap" hidden>Connecteur à enrichir | |
| 80 | − <select id="ef-source" class="select"></select> | |
| 81 | − </label> | |
| 82 | − <label class="flab">Consigne (optionnel) | |
| 83 | − <input id="ef-note" class="input" placeholder="ex.: vise les microbrasseries de la Côte-Nord, ajoute les fiches détail…"> | |
| 84 | − </label> | |
| 85 | − <div class="frow frow-end"> | |
| 86 | − <label class="flab">Jeton d'opérateur | |
| 87 | − <input id="ef-token" type="password" class="input" placeholder="KA_GUARDIAN_TOKEN" autocomplete="off"> | |
| 88 | − </label> | |
| 89 | − <button type="submit" class="btn-primary">Lancer l'effort →</button> | |
| 90 | − </div> | |
| 91 | − <p class="cmd-msg" id="ef-msg"></p> | |
| 92 | − </form> | |
| 93 | − </div> | |
| 94 | − </section> | |
| 95 | − | |
| 96 | 60 | <!-- ============ ÉCRAN + INCIDENTS ============ --> |
| 97 | 61 | <section class="cols"> |
| 98 | 62 | <div class="screen card" id="screen"> |
modified
orchestrator/web/style.css
+20 −0
@@ -51,6 +51,13 @@ a { color: inherit; } | ||
| 51 | 51 | padding: 0 8px 2px; display: inline-block; transform: rotate(-2deg); font-size: 0.72em; } |
| 52 | 52 | #wm-name b, #wm-name { color: var(--ink); } |
| 53 | 53 | .topnav { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } |
| 54 | +.tabs { display: flex; gap: 6px; } | |
| 55 | +.tab { font: 700 13px var(--font-display); text-decoration: none; color: var(--ink-2); | |
| 56 | + border: 1.5px solid transparent; border-radius: var(--r-pill); padding: 6px 14px; | |
| 57 | + transition: transform 0.15s, box-shadow 0.15s; } | |
| 58 | +.tab:hover { border-color: var(--ink); background: var(--surface); transform: translate(-1px, -1px); | |
| 59 | + box-shadow: 3px 3px 0 var(--ink); } | |
| 60 | +.tab.active { background: var(--ink); color: var(--lime); border-color: var(--ink); } | |
| 54 | 61 | .gk-badge { min-height: 30px; font-family: var(--font-mono); letter-spacing: 0.08em; text-transform: uppercase; |
| 55 | 62 | border: 1.5px solid var(--ink); border-radius: var(--r-pill); background: var(--surface); color: var(--ink-2); |
| 56 | 63 | white-space: nowrap; align-items: center; gap: 7px; padding: 3px 10px 4px; font-size: 10px; font-weight: 700; |
@@ -117,6 +124,19 @@ main { padding: clamp(24px, 4vw, 48px) clamp(16px, 4vw, 48px); max-width: 1440px | ||
| 117 | 124 | .cmd-msg { font: 600 12.5px var(--font-mono); min-height: 18px; } |
| 118 | 125 | .cmd-msg.ok { color: var(--green); } .cmd-msg.err { color: var(--danger); } |
| 119 | 126 | |
| 127 | +/* ---------- page /commander ---------- */ | |
| 128 | +.cmd-page { max-width: 980px; } | |
| 129 | +.cmd-hero h1 { font-family: var(--font-display); font-size: clamp(30px, 4.5vw, 48px); | |
| 130 | + letter-spacing: -0.03em; line-height: 1.06; margin: 6px 0 14px; } | |
| 131 | +.cmd-hero .lede { color: var(--ink-2); max-width: 62ch; } | |
| 132 | +.cmd-form { padding: clamp(18px, 3vw, 30px); display: grid; gap: 14px; } | |
| 133 | +.frow-3 { grid-template-columns: 1fr 1fr 1fr; } | |
| 134 | +@media (max-width: 800px) { .frow-3 { grid-template-columns: 1fr; } } | |
| 135 | +.kind-hint { font-size: 13px; color: var(--ink-2); background: var(--surface); | |
| 136 | + border: 1.5px solid var(--line); border-left: 3px solid var(--lime); border-radius: var(--r-ctl); | |
| 137 | + padding: 9px 12px; } | |
| 138 | +.cmd-note { font-size: 12px; color: var(--ink-3); max-width: 52ch; align-self: center; } | |
| 139 | + | |
| 120 | 140 | /* ---------- écran (flux) + side ---------- */ |
| 121 | 141 | .cols { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 20px; align-items: start; } |
| 122 | 142 | @media (max-width: 980px) { .cols { grid-template-columns: 1fr; } } |
modified
runner/runner.py
+31 −1
@@ -110,10 +110,33 @@ class MissionIn(BaseModel): | ||
| 110 | 110 | model: str = "sonnet" |
| 111 | 111 | max_turns: int = 70 |
| 112 | 112 | timeout_seconds: int = 3600 |
| 113 | + max_cost_usd: float | None = None # plafond opérateur (estimation par usage) | |
| 113 | 114 | prompt: str |
| 114 | 115 | callback_url: str # http://<orchestrateur>/api/ingest/<mission_id> |
| 115 | 116 | |
| 116 | 117 | |
| 118 | +# Tarifs $/MTok (entrée, sortie) — pour ESTIMER le coût en cours de mission et | |
| 119 | +# appliquer le plafond opérateur. Cache: lecture ≈ 0,1× entrée, écriture ≈ 1,25×. | |
| 120 | +MODEL_RATES = {"fable": (10.0, 50.0), "opus": (5.0, 25.0), "sonnet": (3.0, 15.0), "haiku": (1.0, 5.0)} | |
| 121 | + | |
| 122 | + | |
| 123 | +def rates_for(model: str) -> tuple[float, float]: | |
| 124 | + for key, r in MODEL_RATES.items(): | |
| 125 | + if key in model: | |
| 126 | + return r | |
| 127 | + return MODEL_RATES["fable"] | |
| 128 | + | |
| 129 | + | |
| 130 | +def usage_cost_usd(usage: dict[str, Any], model: str) -> float: | |
| 131 | + rin, rout = rates_for(model) | |
| 132 | + return ( | |
| 133 | + (usage.get("input_tokens") or 0) * rin | |
| 134 | + + (usage.get("output_tokens") or 0) * rout | |
| 135 | + + (usage.get("cache_read_input_tokens") or 0) * rin * 0.1 | |
| 136 | + + (usage.get("cache_creation_input_tokens") or 0) * rin * 1.25 | |
| 137 | + ) / 1e6 | |
| 138 | + | |
| 139 | + | |
| 117 | 140 | SPOOL = HOME / "ka-guardian-spool" |
| 118 | 141 | |
| 119 | 142 | |
@@ -209,13 +232,14 @@ def run_mission(m: MissionIn) -> None: | ||
| 209 | 232 | ) |
| 210 | 233 | _current["pid"] = proc.pid |
| 211 | 234 | result_text, cost, turns = "", None, None |
| 235 | + spent_estimate = 0.0 | |
| 212 | 236 | deadline = time.time() + m.timeout_seconds |
| 213 | 237 | with transcript.open("w") as tf: |
| 214 | 238 | for line in proc.stdout: # type: ignore[union-attr] |
| 215 | 239 | tf.write(line) |
| 216 | 240 | if time.time() > deadline: |
| 217 | 241 | proc.kill() |
| 218 | − post_event(m.callback_url, {"type": "error", "data": {"error": "timeout mission"}}) | |
| 242 | + post_event(m.callback_url, {"type": "error", "data": {"error": f"durée max atteinte ({m.timeout_seconds // 60} min) — session interrompue"}}) | |
| 219 | 243 | break |
| 220 | 244 | line = line.strip() |
| 221 | 245 | if not line: |
@@ -224,6 +248,12 @@ def run_mission(m: MissionIn) -> None: | ||
| 224 | 248 | obj = json.loads(line) |
| 225 | 249 | except Exception: |
| 226 | 250 | continue |
| 251 | + if obj.get("type") == "assistant": | |
| 252 | + spent_estimate += usage_cost_usd(obj.get("message", {}).get("usage") or {}, m.model) | |
| 253 | + if m.max_cost_usd and spent_estimate > m.max_cost_usd: | |
| 254 | + proc.kill() | |
| 255 | + post_event(m.callback_url, {"type": "error", "data": {"error": f"coût max atteint (~{spent_estimate:.2f} $ estimé, plafond {m.max_cost_usd:.2f} $) — session interrompue"}}) | |
| 256 | + break | |
| 227 | 257 | for ev in condense_stream_line(obj): |
| 228 | 258 | if ev["type"] == "result": |
| 229 | 259 | result_text = ev["data"].get("result", "") |
| 230 | 260 | |