SPB Git

spb/ka-guardian

Public

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>
Simon-Pierre Boucher committed (Aug 23, 2026) parent 39f4a08 Browse files

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 Tu es sur le nœud {node_alias}, répertoire courant = repo de l'app: {svc['dir']} (remote-first, source de vérité). 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 L'app tourne via pm2 ({', '.join(svc['pm2'])}); process de sync: {sync_proc}; site local port {svc['web_port']}. 274 L'app tourne via pm2 ({', '.join(svc['pm2'])}); process de sync: {sync_proc}; site local port {svc['web_port']}.
275 Commence par lire le CLAUDE.md / README du repo et docs/connecteurs/ s'ils existent.""" 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 if kind == "effort_new": 307 if kind == "effort_new":
277 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', '')}). 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 {"Consigne de l'opérateur: " + note if note else "Aucune consigne particulière: choisis la source la plus utile."} 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 mid = uuid.uuid4().hex[:12] 355 mid = uuid.uuid4().hex[:12]
325 my_ip = os.environ.get("KA_GUARDIAN_SELF_IP", "192.168.2.69") 356 my_ip = os.environ.get("KA_GUARDIAN_SELF_IP", "192.168.2.69")
326 kind = incident["status_detected"] 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 prompt = build_effort_prompt(kind, service, source, health.get("note", "")) 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 else: 368 else:
331 prompt = build_prompt(service, source, health) 369 prompt = build_prompt(service, source, health)
332 max_turns, timeout = POLICY["mission_max_turns"], POLICY["mission_timeout_seconds"] 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 "model": ME.get("model", "sonnet"), 374 "model": ME.get("model", "sonnet"),
337 "max_turns": max_turns, 375 "max_turns": max_turns,
338 "timeout_seconds": timeout, 376 "timeout_seconds": timeout,
377+ "max_cost_usd": max_cost,
339 "prompt": prompt, 378 "prompt": prompt,
340 "callback_url": f"http://{my_ip}:{ME['port']}/api/ingest/{mid}", 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 v = verdict.get("verdict", "inconnu") 620 v = verdict.get("verdict", "inconnu")
582 if not inc: 621 if not inc:
583 return 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 # Les efforts commandés ne passent pas par la surveillance api-ka: 624 # Les efforts commandés ne passent pas par la surveillance api-ka:
586 # verdict + santé de l'app décident tout de suite. 625 # verdict + santé de l'app décident tout de suite.
587 if not health.get("ok", True): 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 set_incident(c, inc["id"], state="resolved", resolved=now()) 631 set_incident(c, inc["id"], state="resolved", resolved=now())
593 incident_event(inc["id"], m["service"], m["source"], "resolved", 632 incident_event(inc["id"], m["service"], m["source"], "resolved",
594 f"effort livré ({len(commits)} commit{'s' if len(commits) > 1 else ''})") 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 else: 638 else:
596− if commits: 639+ # Session interrompue (plafond durée/coût) ou verdict illisible:
597− asyncio.create_task(rollback_mission(m2, f"effort verdict {v} → rollback préventif")) 640+ # l'app est saine, les commits incrémentaux testés sont conservés.
598 set_incident(c, inc["id"], state="abandoned") 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 return 644 return
601 if not health.get("ok", True): 645 if not health.get("ok", True):
602 # L'app est tombée → rollback immédiat, quoi qu'ait dit l'agent. 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 kind = body.get("kind") 746 kind = body.get("kind")
703 service = body.get("service") 747 service = body.get("service")
704 note = (body.get("note") or "").strip()[:2000] 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 raise HTTPException(status_code=400, detail="kind ou service invalide") 750 raise HTTPException(status_code=400, detail="kind ou service invalide")
707 if kind == "effort_enrich": 751 if kind == "effort_enrich":
708 source = (body.get("source") or "").strip() 752 source = (body.get("source") or "").strip()
709 if not source: 753 if not source:
710 raise HTTPException(status_code=400, detail="source requise pour un enrichissement") 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 else: 757 else:
712 source = f"nouveau·{uuid.uuid4().hex[:6]}" 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 iid = uuid.uuid4().hex[:10] 767 iid = uuid.uuid4().hex[:10]
714 with db() as c: 768 with db() as c:
715 c.execute("INSERT INTO incidents(id,service,source,status_detected,state,created,updated,detail) " 769 c.execute("INSERT INTO incidents(id,service,source,status_detected,state,created,updated,detail) "
716 "VALUES(?,?,?,?,?,?,?,?)", 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 incident_event(iid, service, source, "open", 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 return {"ok": True, "incident_id": iid} 778 return {"ok": True, "incident_id": iid}
721 779
722 780
@@ -769,6 +827,11 @@ def index() -> FileResponse:
769 return FileResponse(BASE / "web" / "index.html") 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 app.mount("/static", StaticFiles(directory=BASE / "web"), name="static") 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 cooldown: "attente", resolved: "résolu", self_healed: "auto-guéri", abandoned: "abandonné" }; 7 cooldown: "attente", resolved: "résolu", self_healed: "auto-guéri", abandoned: "abandonné" };
8 const STATUSES = ["ok", "degraded", "broken", "stale"]; 8 const STATUSES = ["ok", "degraded", "broken", "stale"];
9 const STATUS_FR = { ok: "ok", degraded: "dégradé", broken: "cassé", stale: "endormi", manual: "manuel", 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 const VERDICT_FR = { repare: "réparé", livre: "livré", echec: "échec", site_source_mort: "source morte", 12 const VERDICT_FR = { repare: "réparé", livre: "livré", echec: "échec", site_source_mort: "source morte",
12 rien_a_faire: "rien à faire", inconnu: "inconnu", erreur: "erreur" }; 13 rien_a_faire: "rien à faire", inconnu: "inconnu", erreur: "erreur" };
13 14
@@ -35,64 +36,7 @@ async function load() {
35 $("#foot-sites").innerHTML = 36 $("#foot-sites").innerHTML =
36 Object.values(STATE.services).map((s) => `<a href="${s.site}">${esc(s.app)}</a>`).join("") + 37 Object.values(STATE.services).map((s) => `<a href="${s.site}">${esc(s.app)}</a>`).join("") +
37 Object.entries(STATE.siblings).map(([a, v]) => `<a href="https://${v.domain}">${a}·guardian</a>`).join(""); 38 Object.entries(STATE.siblings).map(([a, v]) => `<a href="https://${v.domain}">${a}·guardian</a>`).join("");
38− renderTiles(); renderIncidents(); renderCoverage(); renderMissions(); initEffortForm(); 39+ renderTiles(); renderIncidents(); renderCoverage(); renderMissions();
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; }
96 } 40 }
97 41
98 function renderTiles() { 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 <header class="topbar"> 17 <header class="topbar">
18 <a class="wordmark" href="/"><span id="wm-name">ka·—</span><span class="ka" id="wm-ka">guardian</span></a> 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 <nav class="topnav"> 23 <nav class="topnav">
20 <a class="gk-badge" href="https://www.groupe-ka.com">groupe<b><span class="ka">·KA</span></b></a> 24 <a class="gk-badge" href="https://www.groupe-ka.com">groupe<b><span class="ka">·KA</span></b></a>
21 <span id="siblings"></span> 25 <span id="siblings"></span>
@@ -53,46 +57,6 @@
53 <div class="tiles" id="tiles"></div> 57 <div class="tiles" id="tiles"></div>
54 </section> 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 <!-- ============ ÉCRAN + INCIDENTS ============ --> 60 <!-- ============ ÉCRAN + INCIDENTS ============ -->
97 <section class="cols"> 61 <section class="cols">
98 <div class="screen card" id="screen"> 62 <div class="screen card" id="screen">
modified orchestrator/web/style.css +20 −0
@@ -51,6 +51,13 @@ a { color: inherit; }
51 padding: 0 8px 2px; display: inline-block; transform: rotate(-2deg); font-size: 0.72em; } 51 padding: 0 8px 2px; display: inline-block; transform: rotate(-2deg); font-size: 0.72em; }
52 #wm-name b, #wm-name { color: var(--ink); } 52 #wm-name b, #wm-name { color: var(--ink); }
53 .topnav { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } 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 .gk-badge { min-height: 30px; font-family: var(--font-mono); letter-spacing: 0.08em; text-transform: uppercase; 61 .gk-badge { min-height: 30px; font-family: var(--font-mono); letter-spacing: 0.08em; text-transform: uppercase;
55 border: 1.5px solid var(--ink); border-radius: var(--r-pill); background: var(--surface); color: var(--ink-2); 62 border: 1.5px solid var(--ink); border-radius: var(--r-pill); background: var(--surface); color: var(--ink-2);
56 white-space: nowrap; align-items: center; gap: 7px; padding: 3px 10px 4px; font-size: 10px; font-weight: 700; 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 .cmd-msg { font: 600 12.5px var(--font-mono); min-height: 18px; } 124 .cmd-msg { font: 600 12.5px var(--font-mono); min-height: 18px; }
118 .cmd-msg.ok { color: var(--green); } .cmd-msg.err { color: var(--danger); } 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 /* ---------- écran (flux) + side ---------- */ 140 /* ---------- écran (flux) + side ---------- */
121 .cols { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 20px; align-items: start; } 141 .cols { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 20px; align-items: start; }
122 @media (max-width: 980px) { .cols { grid-template-columns: 1fr; } } 142 @media (max-width: 980px) { .cols { grid-template-columns: 1fr; } }
modified runner/runner.py +31 −1
@@ -110,10 +110,33 @@ class MissionIn(BaseModel):
110 model: str = "sonnet" 110 model: str = "sonnet"
111 max_turns: int = 70 111 max_turns: int = 70
112 timeout_seconds: int = 3600 112 timeout_seconds: int = 3600
113+ max_cost_usd: float | None = None # plafond opérateur (estimation par usage)
113 prompt: str 114 prompt: str
114 callback_url: str # http://<orchestrateur>/api/ingest/<mission_id> 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 SPOOL = HOME / "ka-guardian-spool" 140 SPOOL = HOME / "ka-guardian-spool"
118 141
119 142
@@ -209,13 +232,14 @@ def run_mission(m: MissionIn) -> None:
209 ) 232 )
210 _current["pid"] = proc.pid 233 _current["pid"] = proc.pid
211 result_text, cost, turns = "", None, None 234 result_text, cost, turns = "", None, None
235+ spent_estimate = 0.0
212 deadline = time.time() + m.timeout_seconds 236 deadline = time.time() + m.timeout_seconds
213 with transcript.open("w") as tf: 237 with transcript.open("w") as tf:
214 for line in proc.stdout: # type: ignore[union-attr] 238 for line in proc.stdout: # type: ignore[union-attr]
215 tf.write(line) 239 tf.write(line)
216 if time.time() > deadline: 240 if time.time() > deadline:
217 proc.kill() 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 break 243 break
220 line = line.strip() 244 line = line.strip()
221 if not line: 245 if not line:
@@ -224,6 +248,12 @@ def run_mission(m: MissionIn) -> None:
224 obj = json.loads(line) 248 obj = json.loads(line)
225 except Exception: 249 except Exception:
226 continue 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 for ev in condense_stream_line(obj): 257 for ev in condense_stream_line(obj):
228 if ev["type"] == "result": 258 if ev["type"] == "result":
229 result_text = ev["data"].get("result", "") 259 result_text = ev["data"].get("result", "")
230 260
Menu

Theme