Files
legal-ai/web-ui/src/app/settings/_components/agents-tab.tsx
Chaim 69e153b3db
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 32s
fix(settings/agents): exclude noise from drift detection
Two false positives surfaced after the Agents tab went live:

1. status (running/idle/paused) is runtime state, not config — drops in
   and out as agents pick up issues. Removed from _DRIFT_FIELDS.

2. desiredSkills compared raw, but local/* and company/* skills carry
   per-company hashes/scopes by design (sync_agents_across_companies.py
   filters local skills with a warning). Comparing them flags every
   master+mirror pair that has any local skill on master.

Now compares only paperclipai/* skills (vendor-shipped, must match).
UI shows an inline note explaining the filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:39:17 +00:00

373 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import {
AlertCircle,
Bot,
ChevronDown,
ChevronUp,
PauseCircle,
PlayCircle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
usePaperclipAgents,
type AgentPair,
type DriftEntry,
type PaperclipAgent,
} from "@/lib/api/paperclip-agents";
const ROLE_LABEL: Record<string, string> = {
ceo: "CEO",
researcher: "מחקר",
engineer: "כתיבה",
qa: "בקרה",
general: "כללי",
};
const FIELD_LABEL: Record<string, string> = {
model: "מודל",
effort: "effort",
timeoutSec: "timeout (שניות)",
maxTurnsPerRun: "max turns",
desiredSkills: "skills",
instructionsBundleMode: "bundle mode",
instructionsEntryFile: "entry file",
graceSec: "grace (שניות)",
cooldownSec: "cooldown (שניות)",
wakeOnDemand: "wake on demand",
maxConcurrentRuns: "max concurrent",
budget_monthly_cents: "תקציב חודשי",
status: "סטטוס",
};
function formatCents(cents: number | null): string {
if (cents == null) return "—";
return `$${(cents / 100).toFixed(2)}`;
}
function StatusBadge({ agent }: { agent: PaperclipAgent }) {
const status = agent.status ?? "unknown";
if (status === "paused" || status === "terminated") {
return (
<Badge variant="outline" className="bg-warn-bg text-warn border-warn/40">
<PauseCircle className="w-3 h-3 me-1" />
{status === "paused" ? "מושהה" : "סיים"}
</Badge>
);
}
return (
<Badge variant="outline" className="bg-success-bg text-success border-success/40">
<PlayCircle className="w-3 h-3 me-1" />
פעיל
</Badge>
);
}
function FieldRow({
label,
master,
mirror,
drifted,
mono,
}: {
label: string;
master: React.ReactNode;
mirror: React.ReactNode;
drifted: boolean;
mono?: boolean;
}) {
const cellBase = `tabular-nums text-[0.82rem] ${mono ? "font-mono" : ""}`;
const cellCls = (val: React.ReactNode) =>
`${cellBase} px-2 py-1 rounded ${
drifted ? "bg-warn-bg text-warn border border-warn/40" : "text-ink"
} ${val == null || val === "—" ? "text-ink-light" : ""}`;
return (
<div className="grid grid-cols-[7rem_1fr_1fr] gap-2 items-center">
<div className="text-[0.75rem] text-ink-muted">{label}</div>
<div className={cellCls(master)} dir="ltr">{master ?? "—"}</div>
<div className={cellCls(mirror)} dir="ltr">{mirror ?? "—"}</div>
</div>
);
}
function PairCard({ pair }: { pair: AgentPair }) {
const [expanded, setExpanded] = useState(false);
const driftFields = new Set(pair.drift.map((d) => d.field));
const driftCount = pair.drift.length;
const pairMissing = driftFields.has("_pair_missing");
const a = pair.master ?? pair.mirror;
if (!a) return null;
const fieldVal = (
side: "master" | "mirror",
key: keyof PaperclipAgent,
): React.ReactNode => {
const agent = pair[side];
if (!agent) return <span className="text-ink-light"></span>;
const v = agent[key];
if (v == null) return "—";
if (typeof v === "boolean") return v ? "✓" : "✗";
if (Array.isArray(v)) return `${v.length}`;
return String(v);
};
const skillsList = (agent: PaperclipAgent | null) =>
agent?.desiredSkills?.length ? agent.desiredSkills : [];
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4 space-y-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<Bot className="w-5 h-5 text-gold-deep shrink-0" />
<div>
<h3 className="text-navy font-semibold text-base mb-0">{pair.name}</h3>
<div className="flex items-center gap-2 mt-0.5">
<Badge variant="outline" className="text-[0.7rem]">
{ROLE_LABEL[pair.role ?? ""] ?? pair.role ?? "—"}
</Badge>
{pair.master && <StatusBadge agent={pair.master} />}
</div>
</div>
</div>
{pairMissing ? (
<Badge variant="outline" className="bg-danger-bg text-danger border-danger/40">
<AlertCircle className="w-3 h-3 me-1" />
{pair.master ? "חסר ב-CMPA" : "חסר ב-CMP"}
</Badge>
) : driftCount > 0 ? (
<Badge variant="outline" className="bg-warn-bg text-warn border-warn/40">
<AlertCircle className="w-3 h-3 me-1" />
{driftCount} פערים
</Badge>
) : (
<Badge variant="outline" className="bg-success-bg text-success border-success/40">
מסונכרן
</Badge>
)}
</div>
<div className="grid grid-cols-[7rem_1fr_1fr] gap-2 text-[0.7rem] uppercase tracking-wide text-ink-muted border-b border-rule pb-1">
<div></div>
<div>CMP (1xxx)</div>
<div>CMPA (8xxx)</div>
</div>
<div className="space-y-1">
<FieldRow label={FIELD_LABEL.model} master={fieldVal("master", "model")} mirror={fieldVal("mirror", "model")} drifted={driftFields.has("model")} mono />
<FieldRow label={FIELD_LABEL.effort} master={fieldVal("master", "effort")} mirror={fieldVal("mirror", "effort")} drifted={driftFields.has("effort")} />
<FieldRow label={FIELD_LABEL.timeoutSec} master={fieldVal("master", "timeoutSec")} mirror={fieldVal("mirror", "timeoutSec")} drifted={driftFields.has("timeoutSec")} />
<FieldRow label={FIELD_LABEL.maxTurnsPerRun} master={fieldVal("master", "maxTurnsPerRun")} mirror={fieldVal("mirror", "maxTurnsPerRun")} drifted={driftFields.has("maxTurnsPerRun")} />
<FieldRow
label={FIELD_LABEL.desiredSkills}
master={pair.master ? `${pair.master.desiredSkills.length}` : "—"}
mirror={pair.mirror ? `${pair.mirror.desiredSkills.length}` : "—"}
drifted={driftFields.has("desiredSkills")}
/>
<FieldRow label={FIELD_LABEL.graceSec} master={fieldVal("master", "graceSec")} mirror={fieldVal("mirror", "graceSec")} drifted={driftFields.has("graceSec")} />
<FieldRow label={FIELD_LABEL.cooldownSec} master={fieldVal("master", "cooldownSec")} mirror={fieldVal("mirror", "cooldownSec")} drifted={driftFields.has("cooldownSec")} />
<FieldRow label={FIELD_LABEL.wakeOnDemand} master={fieldVal("master", "wakeOnDemand")} mirror={fieldVal("mirror", "wakeOnDemand")} drifted={driftFields.has("wakeOnDemand")} />
<FieldRow label={FIELD_LABEL.maxConcurrentRuns} master={fieldVal("master", "maxConcurrentRuns")} mirror={fieldVal("mirror", "maxConcurrentRuns")} drifted={driftFields.has("maxConcurrentRuns")} />
<FieldRow
label={FIELD_LABEL.budget_monthly_cents}
master={
pair.master
? `${formatCents(pair.master.spent_monthly_cents)} / ${formatCents(pair.master.budget_monthly_cents)}`
: "—"
}
mirror={
pair.mirror
? `${formatCents(pair.mirror.spent_monthly_cents)} / ${formatCents(pair.mirror.budget_monthly_cents)}`
: "—"
}
drifted={driftFields.has("budget_monthly_cents")}
/>
<FieldRow label={FIELD_LABEL.instructionsBundleMode} master={fieldVal("master", "instructionsBundleMode")} mirror={fieldVal("mirror", "instructionsBundleMode")} drifted={driftFields.has("instructionsBundleMode")} mono />
<FieldRow label={FIELD_LABEL.instructionsEntryFile} master={fieldVal("master", "instructionsEntryFile")} mirror={fieldVal("mirror", "instructionsEntryFile")} drifted={driftFields.has("instructionsEntryFile")} mono />
</div>
<div className="flex items-center justify-between pt-1 border-t border-rule">
<Button
variant="ghost"
size="sm"
className="text-[0.78rem] text-ink-muted"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? (
<>
<ChevronUp className="w-3 h-3 me-1" />
כיווץ
</>
) : (
<>
<ChevronDown className="w-3 h-3 me-1" />
פרטים מלאים
</>
)}
</Button>
{pair.master?.updated_at && (
<span className="text-[0.7rem] text-ink-light">
עודכן: {new Date(pair.master.updated_at).toLocaleDateString("he-IL")}
</span>
)}
</div>
{expanded && (
<div className="pt-2 border-t border-rule space-y-3">
{pair.drift.length > 0 && !pairMissing && (
<div className="rounded-md bg-warn-bg/40 border border-warn/30 p-3">
<div className="text-[0.78rem] text-warn font-medium mb-2">פערי סנכרון</div>
<ul className="space-y-1 text-[0.78rem]">
{pair.drift.map((d: DriftEntry) => (
<li key={d.field} className="flex items-center gap-2 flex-wrap">
<code dir="ltr" className="text-[0.72rem]">{FIELD_LABEL[d.field] ?? d.field}</code>
<span className="text-ink-muted">CMP:</span>
<code dir="ltr" className="text-[0.72rem] text-ink">{JSON.stringify(d.master)}</code>
<span className="text-ink-muted">CMPA:</span>
<code dir="ltr" className="text-[0.72rem] text-ink">{JSON.stringify(d.mirror)}</code>
</li>
))}
</ul>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{(["master", "mirror"] as const).map((side) => {
const agent = pair[side];
const skills = skillsList(agent);
return (
<div key={side} className="rounded-md border border-rule p-3 space-y-2">
<div className="text-[0.75rem] text-ink-muted">
{side === "master" ? "CMP" : "CMPA"}
</div>
{agent ? (
<>
<div className="text-[0.72rem] font-mono text-ink-muted" dir="ltr">
id: {agent.id}
</div>
<div>
<div className="text-[0.72rem] text-ink-muted mb-1">
skills ({skills.length})
</div>
{skills.length === 0 ? (
<span className="text-[0.78rem] text-ink-light"></span>
) : (
<ul className="space-y-0.5">
{skills.map((s) => (
<li key={s} className="text-[0.72rem] font-mono" dir="ltr">
{s}
</li>
))}
</ul>
)}
</div>
{agent.instructionsFilePath && (
<div>
<div className="text-[0.72rem] text-ink-muted">instructions path</div>
<code className="text-[0.72rem] font-mono break-all" dir="ltr">
{agent.instructionsFilePath}
</code>
</div>
)}
{agent.pause_reason && (
<div className="text-[0.78rem] text-warn">
סיבת השהיה: {agent.pause_reason}
</div>
)}
</>
) : (
<span className="text-[0.78rem] text-ink-light">חסר</span>
)}
</div>
);
})}
</div>
</div>
)}
</CardContent>
</Card>
);
}
export function AgentsTab() {
const { data, isPending, error, refetch, isFetching } = usePaperclipAgents();
if (error) {
return (
<Card className="bg-surface border-danger/40">
<CardContent className="p-6 flex items-center gap-3 text-danger">
<AlertCircle className="w-5 h-5" />
<span>שגיאה: {error.message}</span>
</CardContent>
</Card>
);
}
if (isPending) {
return (
<div className="space-y-3">
{[...Array(7)].map((_, i) => (
<Skeleton key={i} className="h-48 w-full rounded-lg" />
))}
</div>
);
}
if (!data || data.pairs.length === 0) {
return (
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-12 text-center text-ink-muted">
לא נמצאו סוכנים
</CardContent>
</Card>
);
}
const totalDrift = data.pairs.reduce(
(sum, p) => sum + p.drift.filter((d) => d.field !== "_pair_missing").length,
0,
);
const missingCount = data.pairs.filter((p) => !p.master || !p.mirror).length;
return (
<div className="space-y-4">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4 flex items-center justify-between gap-3 flex-wrap">
<div className="space-y-1">
<div className="text-[0.85rem] text-ink-muted">
{data.pairs.length} סוכנים × 2 חברות (CMP master / CMPA mirror)
{totalDrift > 0 && (
<span className="text-warn ms-2">
· {totalDrift} פערי סנכרון
</span>
)}
{missingCount > 0 && (
<span className="text-danger ms-2">· {missingCount} זוגות לא שלמים</span>
)}
</div>
<div className="text-[0.7rem] text-ink-light">
פערי skills מחושבים על paperclipai/* בלבד. local/* ו-company/* מסוננים שם שונה בין החברות הוא צפוי.
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
>
רענון
</Button>
</CardContent>
</Card>
<div className="space-y-3">
{data.pairs.map((pair) => (
<PairCard key={pair.name} pair={pair} />
))}
</div>
</div>
);
}