All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 32s
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>
373 lines
14 KiB
TypeScript
373 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|