Merge pull request 'feat(ops): פאנל "מתאמי-סוכנים" ב-/operations — מעבר-אדפטר בכפתור' (#248) from worktree-adapter-migrate-ui into main
This commit was merged in pull request #248.
This commit is contained in:
@@ -288,6 +288,76 @@ async def fetch(request: web.Request) -> web.Response:
|
|||||||
return web.json_response({"ok": False, "reason": f"unexpected: {e}"}, status=200)
|
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:
|
def build_app() -> web.Application:
|
||||||
app = web.Application(client_max_size=64 * 1024 * 1024)
|
app = web.Application(client_max_size=64 * 1024 * 1024)
|
||||||
app.router.add_get("/health", health)
|
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_get("/usage", usage_status)
|
||||||
app.router.add_post("/pm2/control", pm2_control)
|
app.router.add_post("/pm2/control", pm2_control)
|
||||||
app.router.add_post("/fetch", fetch)
|
app.router.add_post("/fetch", fetch)
|
||||||
|
app.router.add_post("/adapter-migration", adapter_migration)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
import { SystemHealthSection } from "@/components/operations/system-health-section";
|
import { SystemHealthSection } from "@/components/operations/system-health-section";
|
||||||
|
import { AgentAdaptersPanel } from "@/components/operations/agent-adapters-panel";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -839,6 +840,9 @@ export default function OperationsPage() {
|
|||||||
<SectionHeader>סוכנים פעילים</SectionHeader>
|
<SectionHeader>סוכנים פעילים</SectionHeader>
|
||||||
<LiveAgentsPanel />
|
<LiveAgentsPanel />
|
||||||
|
|
||||||
|
<SectionHeader>מתאמי-סוכנים</SectionHeader>
|
||||||
|
<AgentAdaptersPanel />
|
||||||
|
|
||||||
<SectionHeader>שירותים</SectionHeader>
|
<SectionHeader>שירותים</SectionHeader>
|
||||||
<ServicesPanel data={data} />
|
<ServicesPanel data={data} />
|
||||||
|
|
||||||
|
|||||||
268
web-ui/src/components/operations/agent-adapters-panel.tsx
Normal file
268
web-ui/src/components/operations/agent-adapters-panel.tsx
Normal file
@@ -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<Record<string, string>>({});
|
||||||
|
const [dlg, setDlg] = useState<PendingDialog | null>(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 (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<p className="text-ink-muted text-xs mb-4">
|
||||||
|
העברת כל סוכן בין מנועי-ההרצה (Claude · Gemini · DeepSeek), בשתי החברות יחד. כל מעבר
|
||||||
|
עובר preflight שמונע קריסה. מעבר ל-Gemini/DeepSeek = fallback מופחת-איכות — להחזיר ל-Claude
|
||||||
|
כשטוקני-Claude חוזרים.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Emergency fallback bar */}
|
||||||
|
<div className="rounded-md border border-gold/40 bg-gold-wash px-4 py-3 mb-4 flex items-center gap-4 flex-wrap">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[0.85rem] text-navy font-semibold">מצב-חירום · טוקני-Claude</div>
|
||||||
|
<div className="text-[0.7rem] text-ink-muted">
|
||||||
|
כשנגמרים הטוקנים — להפיל את כל הצוות ל-Gemini בלחיצה, ולהחזיר כשחוזרים.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ms-auto shrink-0">
|
||||||
|
<Button size="sm" variant="outline" disabled={busy}
|
||||||
|
onClick={() => openRevert("all", "החזרת כל הסוכנים ל-Claude (מצב-מקור)")}>
|
||||||
|
החזר הכל ל-Claude
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="default" disabled={busy}
|
||||||
|
onClick={() => openMigrate("all", "gemini_local", "העברת כל הסוכנים ל-Gemini ⚡")}>
|
||||||
|
העבר הכל ל-Gemini ⚡
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading || !data ? (
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{data.pairs.map((p) => {
|
||||||
|
const cur = currentOf(p);
|
||||||
|
const tgt = targetFor(p);
|
||||||
|
const migrated = cur !== "claude_local" && cur !== null;
|
||||||
|
const asym = isAsymmetric(p);
|
||||||
|
return (
|
||||||
|
<div key={p.name}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-md border border-rule-soft bg-rule-soft/30 px-3 py-2 flex-wrap">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-[0.85rem] text-navy font-semibold">{p.name}</span>
|
||||||
|
<Badge variant="outline" className={`font-normal ${adapterCls(cur)}`}>
|
||||||
|
{adapterLabel(cur)}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-[0.66rem] text-ink-muted font-mono" dir="ltr">
|
||||||
|
{modelOf(p) || "(default)"}
|
||||||
|
</span>
|
||||||
|
{asym ? (
|
||||||
|
<Badge variant="outline" className="font-normal bg-danger-bg text-danger border-danger/40">
|
||||||
|
⚠ א-סימטרי בין החברות
|
||||||
|
</Badge>
|
||||||
|
) : migrated ? (
|
||||||
|
<Badge variant="outline" className="font-normal bg-warn-bg text-warn border-warn/40">
|
||||||
|
מועבר · fallback
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<Select value={tgt} onValueChange={(v) => setTargets((t) => ({ ...t, [p.name]: v }))}>
|
||||||
|
<SelectTrigger size="sm" className="w-[8.5rem]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ADAPTERS.map((a) => (
|
||||||
|
<SelectItem key={a.value} value={a.value}>{a.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="xs" variant="default" disabled={busy || tgt === cur}
|
||||||
|
onClick={() => openMigrate(p.name, tgt, `העברת "${p.name}" → ${adapterLabel(tgt)}`)}>
|
||||||
|
העבר
|
||||||
|
</Button>
|
||||||
|
{migrated ? (
|
||||||
|
<Button size="xs" variant="ghost" disabled={busy}
|
||||||
|
onClick={() => openRevert(p.name, `החזרת "${p.name}" למצב-מקור`)}>
|
||||||
|
החזר
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Confirm dialog: preflight output + apply/revert */}
|
||||||
|
<Dialog open={!!dlg} onOpenChange={(o) => !o && setDlg(null)}>
|
||||||
|
<DialogContent className="max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{dlg?.title}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{dlg?.body.action === "revert"
|
||||||
|
? "שחזור מדויק למתאם ולמודל שהיו לפני המעבר, בשתי החברות."
|
||||||
|
: "המעבר חל על שתי החברות (CMP + CMPA). Gemini/DeepSeek = fallback מופחת-איכות."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{dlg?.body.action === "apply" ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-[0.72rem] text-ink-muted">preflight:</div>
|
||||||
|
{dlg.loadingPreflight ? (
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
) : (
|
||||||
|
<pre dir="ltr"
|
||||||
|
className={`text-[0.7rem] font-mono whitespace-pre-wrap max-h-56 overflow-auto rounded-md border p-2 ${
|
||||||
|
preflightBlocked ? "border-danger/40 bg-danger-bg" : "border-rule-soft bg-parchment"
|
||||||
|
}`}>
|
||||||
|
{(dlg.preflight?.stdout || "") + (dlg.preflight?.stderr || "") || "—"}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{dlg.showRelax ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch id="relax" checked={dlg.relax}
|
||||||
|
onCheckedChange={(c) => setDlg((d) => d && { ...d, relax: c })} />
|
||||||
|
<Label htmlFor="relax" className="text-[0.75rem] text-ink-soft">
|
||||||
|
שחרר כלי-כתיבה חסומים (excludeTools גלובלי לכל סוכני-Gemini)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setDlg(null)} disabled={busy}>
|
||||||
|
ביטול
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" disabled={busy || dlg?.loadingPreflight || preflightBlocked}
|
||||||
|
onClick={confirmDialog}>
|
||||||
|
{dlg?.body.action === "revert" ? "החזר" : preflightBlocked ? "preflight חוסם" : "אשר העברה"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -228,3 +228,43 @@ export function useResetAgentSession() {
|
|||||||
onError: (e) => toast.error(`האיפוס נכשל: ${String(e)}`),
|
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<MigrateAdapterResult>("/api/operations/agents/migrate-adapter", {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["settings", "paperclip-agents"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["operations", "agents"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
45
web/app.py
45
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}
|
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}")
|
@app.get("/api/digests/{digest_id}")
|
||||||
async def digest_get(digest_id: str):
|
async def digest_get(digest_id: str):
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user