feat(ops): פאנל "מתאמי-סוכנים" ב-/operations — מעבר-אדפטר בכפתור (any→any)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s

שלב-ה-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 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 12:06:40 +00:00
parent b9e4c1fde4
commit 64612240d5
5 changed files with 428 additions and 0 deletions

View File

@@ -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() {
<SectionHeader>סוכנים פעילים</SectionHeader>
<LiveAgentsPanel />
<SectionHeader>מתאמי-סוכנים</SectionHeader>
<AgentAdaptersPanel />
<SectionHeader>שירותים</SectionHeader>
<ServicesPanel data={data} />

View 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>
);
}

View File

@@ -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<MigrateAdapterResult>("/api/operations/agents/migrate-adapter", {
method: "POST",
body,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["settings", "paperclip-agents"] });
qc.invalidateQueries({ queryKey: ["operations", "agents"] });
},
});
}