From 64612240d56ea9b3e76df6de7ab2cecdde1d424e Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 13 Jun 2026 12:06:40 +0000 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=D7=A4=D7=90=D7=A0=D7=9C=20"=D7=9E?= =?UTF-8?q?=D7=AA=D7=90=D7=9E=D7=99-=D7=A1=D7=95=D7=9B=D7=A0=D7=99=D7=9D"?= =?UTF-8?q?=20=D7=91-/operations=20=E2=80=94=20=D7=9E=D7=A2=D7=91=D7=A8-?= =?UTF-8?q?=D7=90=D7=93=D7=A4=D7=98=D7=A8=20=D7=91=D7=9B=D7=A4=D7=AA=D7=95?= =?UTF-8?q?=D7=A8=20(any=E2=86=92any)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit שלב-ה-UI של מנגנון מעבר-האדפטר (PR #247). הכפתור ב-/operations מריץ את scripts/migrate_agent_adapter.py בהוסט דרך גשר-court-fetch (הקונטיינר לא יכול לבצע — צריך FS-הוסט + DB-המובנה), בדיוק כמו כפתורי-pm2. - court_fetch_service/server.py: endpoint /adapter-migration מאומת (Bearer, COURT_FETCH_SHARED_SECRET) שמריץ את הסקריפט עם allowlist-פעולות ו-args אטומיים (exec, ללא shell; הסקריפט מאמת). symbol-light לשמירת G12 בשכבת mcp-server. - web/app.py: proxy POST /api/operations/agents/migrate-adapter → הגשר. - web-ui: useMigrateAdapter (operations.ts) + AgentAdaptersPanel לפי המוקאפ המאושר 02d-operations-adapters.html: roster per-role (מתאם נוכחי+מודל+בורר-יעד), סרגל-חירום גלובלי (הכל→Gemini/החזר→Claude), preflight בדיאלוג + toggle שחרור-כלים, דגלי מועבר/א-סימטרי. מחזר usePaperclipAgents לתצוגה. עיצוב אושר ע"י חיים בשער Claude Design (פרויקט IA Redesign X17). נבדק: tsc --noEmit נקי, eslint נקי. Invariants: G12 (גשר symbol-light; הסקריפט בשכבת scripts/ הוא שמדבר Paperclip), INV-MC1 (שתי החברות יחד), INV-IA "מקום אחד" (/operations). המשך FU-8a. Co-Authored-By: Claude Opus 4.8 --- .../legal_mcp/court_fetch_service/server.py | 71 +++++ web-ui/src/app/operations/page.tsx | 4 + .../operations/agent-adapters-panel.tsx | 268 ++++++++++++++++++ web-ui/src/lib/api/operations.ts | 40 +++ web/app.py | 45 +++ 5 files changed, 428 insertions(+) create mode 100644 web-ui/src/components/operations/agent-adapters-panel.tsx diff --git a/mcp-server/src/legal_mcp/court_fetch_service/server.py b/mcp-server/src/legal_mcp/court_fetch_service/server.py index 28108ff..50d0992 100644 --- a/mcp-server/src/legal_mcp/court_fetch_service/server.py +++ b/mcp-server/src/legal_mcp/court_fetch_service/server.py @@ -288,6 +288,76 @@ async def fetch(request: web.Request) -> web.Response: return web.json_response({"ok": False, "reason": f"unexpected: {e}"}, status=200) +# ─── adapter-migration: host-side runner for scripts/migrate_agent_adapter.py ─── +# The legal-ai container can't perform the migration itself (it needs the host +# filesystem — generated instruction copies, the gemini settings file — plus the +# embedded board DB), so the dashboard proxies the action here. Mutating, so it +# requires the Bearer secret like /pm2/control. We launch exactly one fixed, +# in-repo script with create_subprocess_exec (no shell) and an action allowlist; +# every other argument is passed through opaque and validated by the script +# itself. Kept deliberately symbol-light so this host bridge stays generic. +_MIGRATE_SCRIPT = "/home/chaim/legal-ai/scripts/migrate_agent_adapter.py" +_MIGRATE_PYTHON = "/home/chaim/legal-ai/mcp-server/.venv/bin/python" +_MIGRATE_ACTIONS = {"check", "apply", "revert", "verify"} + + +async def adapter_migration(request: web.Request) -> web.Response: + """Run scripts/migrate_agent_adapter.py on the host and relay its result.""" + unauth = _check_bearer(request) + if unauth is not None: + return unauth + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"error": "invalid JSON body"}, status=400) + + action = str(body.get("action", "")).strip() + if action not in _MIGRATE_ACTIONS: + return web.json_response( + {"error": f"action must be one of {sorted(_MIGRATE_ACTIONS)}"}, status=400 + ) + + argv = [_MIGRATE_PYTHON, _MIGRATE_SCRIPT, f"--{action}"] + agent = str(body.get("agent", "")).strip() + target = str(body.get("to", "")).strip() + model = str(body.get("model", "")).strip() + if action in ("check", "apply", "revert"): + if not agent: + return web.json_response({"error": "agent required"}, status=400) + argv += ["--agent", agent] + if action in ("check", "apply"): + if not target: + return web.json_response({"error": "to (target) required"}, status=400) + argv += ["--to", target] + if model: + argv += ["--model", model] + if bool(body.get("relax_tools")): + argv += ["--relax-tools"] + + import asyncio as _asyncio + + env = {**os.environ, "HOME": "/home/chaim"} + try: + proc = await _asyncio.create_subprocess_exec( + *argv, cwd="/home/chaim/legal-ai", env=env, + stdout=_asyncio.subprocess.PIPE, stderr=_asyncio.subprocess.PIPE, + ) + out, err = await _asyncio.wait_for(proc.communicate(), timeout=180) + except _asyncio.TimeoutError: + return web.json_response({"ok": False, "error": "migration timed out"}, status=504) + except Exception as e: # never throw — relay the failure + return web.json_response({"ok": False, "error": f"launch failed: {e}"}, status=502) + + # 200 regardless of exit code: a non-zero --check (preflight refusal) is an + # informative result the caller renders, not a transport error. + return web.json_response({ + "ok": (proc.returncode == 0), + "exit_code": proc.returncode, + "stdout": out.decode("utf-8", "replace"), + "stderr": err.decode("utf-8", "replace"), + }) + + def build_app() -> web.Application: app = web.Application(client_max_size=64 * 1024 * 1024) app.router.add_get("/health", health) @@ -295,6 +365,7 @@ def build_app() -> web.Application: app.router.add_get("/usage", usage_status) app.router.add_post("/pm2/control", pm2_control) app.router.add_post("/fetch", fetch) + app.router.add_post("/adapter-migration", adapter_migration) return app diff --git a/web-ui/src/app/operations/page.tsx b/web-ui/src/app/operations/page.tsx index 483e797..f67c5e6 100644 --- a/web-ui/src/app/operations/page.tsx +++ b/web-ui/src/app/operations/page.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import Link from "next/link"; import { AppShell } from "@/components/app-shell"; import { SystemHealthSection } from "@/components/operations/system-health-section"; +import { AgentAdaptersPanel } from "@/components/operations/agent-adapters-panel"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -839,6 +840,9 @@ export default function OperationsPage() { סוכנים פעילים + מתאמי-סוכנים + + שירותים diff --git a/web-ui/src/components/operations/agent-adapters-panel.tsx b/web-ui/src/components/operations/agent-adapters-panel.tsx new file mode 100644 index 0000000..d235b1a --- /dev/null +++ b/web-ui/src/components/operations/agent-adapters-panel.tsx @@ -0,0 +1,268 @@ +"use client"; + +/** + * מתאמי-סוכנים (Adapter) — /operations panel. + * + * Migrate any committee agent between run-engines (Claude / Gemini / DeepSeek) + * in BOTH companies at once, with a preflight that prevents the silent crash + * (frontmatter `---` breaks the gemini/deepseek CLI; model must match provider; + * gemini excludeTools is global). Built to the Claude Design mockup + * `02d-operations-adapters.html`. The actual migration runs host-side + * (scripts/migrate_agent_adapter.py) via the court-fetch bridge — see + * useMigrateAdapter / POST /api/operations/agents/migrate-adapter. + */ + +import { useState } from "react"; +import { toast } from "sonner"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { + usePaperclipAgents, + type AgentPair, +} from "@/lib/api/paperclip-agents"; +import { + useMigrateAdapter, + type MigrateAdapterRequest, + type MigrateAdapterResult, +} from "@/lib/api/operations"; + +const ADAPTERS = [ + { value: "claude_local", label: "Claude", cls: "bg-info-bg text-info border-info/40" }, + { value: "gemini_local", label: "Gemini", cls: "bg-warn-bg text-warn border-warn/40" }, + { value: "deepseek_local", label: "DeepSeek", cls: "bg-gold-wash text-gold-deep border-gold/40" }, +] as const; + +function adapterCls(a: string | null | undefined): string { + return ADAPTERS.find((x) => x.value === a)?.cls ?? "bg-rule-soft text-ink-muted border-rule"; +} +function adapterLabel(a: string | null | undefined): string { + return ADAPTERS.find((x) => x.value === a)?.label ?? a ?? "—"; +} + +/** A pending confirmation: the apply/revert to run, plus its preflight result. */ +type PendingDialog = { + title: string; + body: MigrateAdapterRequest; // action is "apply" or "revert" + preflight: MigrateAdapterResult | null; + loadingPreflight: boolean; + relax: boolean; + showRelax: boolean; +}; + +export function AgentAdaptersPanel() { + const { data, isLoading } = usePaperclipAgents(); + const migrate = useMigrateAdapter(); + const [targets, setTargets] = useState>({}); + const [dlg, setDlg] = useState(null); + const busy = migrate.isPending; + + const currentOf = (p: AgentPair) => + p.master?.adapter_type ?? p.mirror?.adapter_type ?? null; + const modelOf = (p: AgentPair) => p.master?.model ?? p.mirror?.model ?? ""; + const isAsymmetric = (p: AgentPair) => + !!p.master && !!p.mirror && p.master.adapter_type !== p.mirror.adapter_type; + const targetFor = (p: AgentPair) => targets[p.name] ?? currentOf(p) ?? "claude_local"; + + // Run a preflight (check) and open the confirm dialog pre-loaded with its output. + async function openMigrate(agent: string, to: string, title: string) { + setDlg({ title, body: { action: "apply", agent, to, relax_tools: true }, + preflight: null, loadingPreflight: true, relax: true, showRelax: false }); + try { + const res = await migrate.mutateAsync({ action: "check", agent, to }); + const conflict = /excludeTools|כלי-כתיבה/.test(res.stdout); + setDlg((d) => d && { ...d, preflight: res, loadingPreflight: false, + showRelax: conflict, relax: conflict }); + } catch (e) { + setDlg((d) => d && { ...d, loadingPreflight: false, + preflight: { ok: false, exit_code: -1, stdout: "", stderr: String(e) } }); + } + } + + function openRevert(agent: string, title: string) { + setDlg({ title, body: { action: "revert", agent }, preflight: null, + loadingPreflight: false, relax: false, showRelax: false }); + } + + // Confirm → run the dialog's apply/revert and toast on the script's exit code. + async function confirmDialog() { + if (!dlg) return; + const body = { ...dlg.body, relax_tools: dlg.relax }; + try { + const res = await migrate.mutateAsync(body); + if (res.exit_code === 0) { + toast.success(dlg.body.action === "revert" ? "הוחזר ל-Claude" : "המעבר הושלם בשתי החברות"); + } else { + toast.error("הפעולה נכשלה — ראה פלט"); + // keep dialog open so the operator sees the failure output + setDlg((d) => d && { ...d, preflight: res, loadingPreflight: false }); + return; + } + } catch (e) { + toast.error(`שגיאה: ${String(e)}`); + return; + } + setDlg(null); + } + + const preflightBlocked = !!dlg?.preflight && dlg.body.action === "apply" && dlg.preflight.exit_code !== 0; + + return ( + + +

+ העברת כל סוכן בין מנועי-ההרצה (Claude · Gemini · DeepSeek), בשתי החברות יחד. כל מעבר + עובר preflight שמונע קריסה. מעבר ל-Gemini/DeepSeek = fallback מופחת-איכות — להחזיר ל-Claude + כשטוקני-Claude חוזרים. +

+ + {/* Emergency fallback bar */} +
+
+
מצב-חירום · טוקני-Claude
+
+ כשנגמרים הטוקנים — להפיל את כל הצוות ל-Gemini בלחיצה, ולהחזיר כשחוזרים. +
+
+
+ + +
+
+ + {isLoading || !data ? ( + + ) : ( +
+ {data.pairs.map((p) => { + const cur = currentOf(p); + const tgt = targetFor(p); + const migrated = cur !== "claude_local" && cur !== null; + const asym = isAsymmetric(p); + return ( +
+
+
+ {p.name} + + {adapterLabel(cur)} + + + {modelOf(p) || "(default)"} + + {asym ? ( + + ⚠ א-סימטרי בין החברות + + ) : migrated ? ( + + מועבר · fallback + + ) : null} +
+
+
+ + + {migrated ? ( + + ) : null} +
+
+ ); + })} +
+ )} +
+ + {/* Confirm dialog: preflight output + apply/revert */} + !o && setDlg(null)}> + + + {dlg?.title} + + {dlg?.body.action === "revert" + ? "שחזור מדויק למתאם ולמודל שהיו לפני המעבר, בשתי החברות." + : "המעבר חל על שתי החברות (CMP + CMPA). Gemini/DeepSeek = fallback מופחת-איכות."} + + + + {dlg?.body.action === "apply" ? ( +
+
preflight:
+ {dlg.loadingPreflight ? ( + + ) : ( +
+                  {(dlg.preflight?.stdout || "") + (dlg.preflight?.stderr || "") || "—"}
+                
+ )} + {dlg.showRelax ? ( +
+ setDlg((d) => d && { ...d, relax: c })} /> + +
+ ) : null} +
+ ) : null} + + + + + +
+
+
+ ); +} diff --git a/web-ui/src/lib/api/operations.ts b/web-ui/src/lib/api/operations.ts index 26c0a39..8d8926e 100644 --- a/web-ui/src/lib/api/operations.ts +++ b/web-ui/src/lib/api/operations.ts @@ -228,3 +228,43 @@ export function useResetAgentSession() { onError: (e) => toast.error(`האיפוס נכשל: ${String(e)}`), }); } + +// ── Agent adapter migration ──────────────────────────────────────────────── +// Migrate an agent (or "all") between run-engines (claude_local / gemini_local / +// deepseek_local) in BOTH companies. Host-side (runs scripts/migrate_agent_adapter.py +// via the court-fetch bridge), so the script's exit code + output are relayed so +// the panel can render preflight warnings. A non-zero exit on a "check" is an +// informative refusal, not a transport error — callers inspect exit_code. +export type MigrateAction = "check" | "apply" | "revert" | "verify"; + +export type MigrateAdapterRequest = { + action: MigrateAction; + agent?: string; // agent display-name, or "all" + to?: string; // target adapter (for check/apply) + model?: string; + relax_tools?: boolean; +}; + +export type MigrateAdapterResult = { + ok: boolean; + exit_code: number; + stdout: string; + stderr: string; +}; + +/** Run an adapter migration action on the host bridge. Caller handles toasts — + * the meaning of the result depends on the action (preflight vs apply vs verify). */ +export function useMigrateAdapter() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: MigrateAdapterRequest) => + apiRequest("/api/operations/agents/migrate-adapter", { + method: "POST", + body, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["settings", "paperclip-agents"] }); + qc.invalidateQueries({ queryKey: ["operations", "agents"] }); + }, + }); +} diff --git a/web/app.py b/web/app.py index 749845a..36ecb8e 100644 --- a/web/app.py +++ b/web/app.py @@ -6900,6 +6900,51 @@ async def operations_agent_reset_session(agent_id: str): return {"ok": True, "agent_id": agent_id, "result": result} +class AdapterMigrationRequest(BaseModel): + action: str # check | apply | revert | verify + agent: str | None = None # agent display-name, or "all" + to: str | None = None # target adapter (for check/apply) + model: str | None = None # optional model override + relax_tools: bool = False # free conflicting write tools from the global gemini excludeTools + + +@app.post("/api/operations/agents/migrate-adapter") +async def operations_agent_migrate_adapter(req: AdapterMigrationRequest): + """Migrate an agent (or 'all') to a target adapter, in both companies. + + The migration is host-side (it needs the host filesystem — generated + instruction copies, the gemini settings file — and the embedded board DB), + so this proxies scripts/migrate_agent_adapter.py through the court-fetch host + bridge, Bearer-authenticated exactly like the pm2 controls. The script's exit + code + stdout/stderr are relayed verbatim so the dashboard can show preflight + warnings (a non-zero --check is a refusal to render, not a transport error).""" + if req.action not in {"check", "apply", "revert", "verify"}: + raise HTTPException(400, "action חייב להיות check/apply/revert/verify") + secret = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip() + headers = {"Authorization": f"Bearer {secret}"} if secret else {} + body = { + "action": req.action, + "agent": req.agent or "", + "to": req.to or "", + "model": req.model or "", + "relax_tools": req.relax_tools, + } + try: + async with httpx.AsyncClient(timeout=190.0) as client: + r = await client.post( + f"{_COURT_FETCH_SERVICE_URL}/adapter-migration", json=body, headers=headers, + ) + except Exception as e: # host bridge down / unreachable + raise HTTPException(502, f"לא ניתן להגיע לשירות-המארח: {e}") from e + try: + payload = r.json() + except Exception: + payload = {"ok": False, "error": r.text[:300]} + if r.status_code >= 400: + raise HTTPException(r.status_code, payload.get("error", "migration bridge failed")) + return payload + + @app.get("/api/digests/{digest_id}") async def digest_get(digest_id: str): try: -- 2.49.1