SPB Git forge

spb/ka-guardian

Public
26commits 1branches 0releases
4.5 MBsize
maindefault branch
19 days agolast push
Python 57.1% Shell 13.8% CSS 12% JavaScript 10% HTML 7.2%

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 1 mo ago (Aug 23, 2026) parent 39f4a08

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) => ({ "&": "&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 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