Files
legal-ai/web-ui/src/lib/api/operations.ts
Chaim 64612240d5
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
feat(ops): פאנל "מתאמי-סוכנים" ב-/operations — מעבר-אדפטר בכפתור (any→any)
שלב-ה-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>
2026-06-13 12:06:40 +00:00

271 lines
9.1 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { apiRequest } from "./client";
export type OpsService = {
name: string;
status: string;
restarts: number;
uptime_ms: number;
cpu: number;
memory_bytes: number;
cron: string;
autorestart: boolean;
disabled?: boolean; // cron drain switched off via the dashboard
burst_until?: string | null; // manual "run continuously now" window (ISO) — see useDrainBurst
};
export type CourtFetchJob = {
case_number_norm: string;
citation_raw: string;
tier: string;
status: string;
error: string;
updated_at: string;
};
export type IngestedRow = {
case_number: string;
court: string;
source_url: string;
created_at: string;
};
/** The uniform per-pipeline shape every background drain reports. */
export type PipelineStats = {
pending: number; // awaiting processing (status='pending') — not necessarily in the active queue
processing: number; // being worked right now
done: number; // completed
failed: number; // terminal failures (court_fetch folds in 'manual')
queued: number; // explicitly enqueued for the next drain run
running_now: string[]; // human labels of the items currently processing
by_status: Record<string, number>; // raw counts, for the curious
};
/** One claude.ai usage window (5-hour / weekly / weekly-per-model). */
export type UsageWindow = {
utilization: number | null; // 0-100; null when the window is inactive
resets_at: string | null;
};
/** claude.ai subscription usage — the same %s the Claude Code status bar shows,
* via the (undocumented) OAuth usage endpoint proxied by the host bridge.
* null when the endpoint is unreachable. */
export type SubscriptionUsage = {
five_hour?: UsageWindow | null;
seven_day?: UsageWindow | null;
seven_day_opus?: UsageWindow | null;
seven_day_sonnet?: UsageWindow | null;
} | null;
export type OperationsSnapshot = {
services: OpsService[];
services_error: string | null;
subscription_usage: SubscriptionUsage;
pipelines: {
court_fetch: PipelineStats & { recent: CourtFetchJob[] };
metadata_extraction: PipelineStats;
halacha_extraction: PipelineStats;
digests: PipelineStats & { total: number; linked: number };
halacha_review: { by_status: Record<string, number> };
missing_precedents: { by_status: Record<string, number> };
ingested_recent: IngestedRow[];
};
};
export function useOperations() {
return useQuery({
queryKey: ["operations"],
queryFn: ({ signal }) =>
apiRequest<OperationsSnapshot>("/api/operations", { signal }),
refetchInterval: 5000, // live view of background work
staleTime: 3000,
});
}
export type ServiceAction = "restart" | "stop" | "start" | "run-now";
/** Control a background service (daemon restart/stop/start, or run a drain now). */
export function useServiceAction() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ name, action }: { name: string; action: ServiceAction }) =>
apiRequest(`/api/operations/services/${name}/${action}`, { method: "POST" }),
onSuccess: (_d, { action }) => {
const labels: Record<ServiceAction, string> = {
"run-now": "הופעל עכשיו",
restart: "הופעל מחדש",
stop: "נעצר",
start: "הופעל",
};
toast.success(labels[action]);
qc.invalidateQueries({ queryKey: ["operations"] });
},
onError: (e) => toast.error(`הפעולה נכשלה: ${String(e)}`),
});
}
/** Switch a cron drain on/off (its "startup type"). */
export function useDrainToggle() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ name, disabled }: { name: string; disabled: boolean }) =>
apiRequest(`/api/operations/drains/${name}/disabled`, {
method: "POST",
body: { disabled },
}),
onSuccess: (_d, { disabled }) => {
toast.success(disabled ? "התזמון כובה" : "התזמון הופעל");
qc.invalidateQueries({ queryKey: ["operations"] });
},
onError: (e) => toast.error(`העדכון נכשל: ${String(e)}`),
});
}
/**
* Start/stop a drain's MANUAL burst window ("run continuously now until X").
* `until` is a tz-naive local datetime string (e.g. "2026-06-13T18:00") — the
* backend interprets it as Israel time. Omit it to let the server default to the
* upcoming Saturday 18:00. The host supervisor enforces it within ≤15 min.
*/
export function useDrainBurst() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ name, action, until }: { name: string; action: "on" | "off"; until?: string }) =>
apiRequest(`/api/operations/drains/${name}/burst`, {
method: "POST",
body: action === "on" ? { action, ...(until ? { until } : {}) } : { action },
}),
onSuccess: (_d, { action }) => {
toast.success(action === "on" ? "BURST הופעל" : "BURST כובה");
qc.invalidateQueries({ queryKey: ["operations"] });
},
onError: (e) => toast.error(`הפעולה נכשלה: ${String(e)}`),
});
}
// ── Live agents — which agent is working now + its output + controls ───────
export type AgentRun = {
run_id: string;
agent_id: string;
agent_name: string;
company_id: string;
company_label: string;
status: string; // running | queued | ...
invocation_source: string;
trigger_detail: string;
issue_id: string | null;
adapter_type: string;
started_at: string | null;
created_at: string | null;
last_output_at: string | null;
continuation_attempt: number;
silence_level: string; // "" | ok | suspicion | critical
silence_age_ms: number;
};
export type AgentRunsResponse = {
runs: AgentRun[];
running: number;
queued: number;
errors: string[];
};
export type RunLog = {
runId: string;
store: string;
logRef: string;
content: string; // NDJSON stream the adapter captured
};
/** Queued + running heartbeat runs across all companies. */
export function useAgentRuns() {
return useQuery({
queryKey: ["operations", "agents"],
queryFn: ({ signal }) =>
apiRequest<AgentRunsResponse>("/api/operations/agents", { signal }),
refetchInterval: 4000, // live view of who's working now
staleTime: 2000,
});
}
/** Full output log of one run — fetched on demand (drawer open). */
export function useRunLog(runId: string | null) {
return useQuery({
queryKey: ["operations", "agents", "log", runId],
queryFn: ({ signal }) =>
apiRequest<RunLog>(`/api/operations/agents/runs/${runId}/log`, { signal }),
enabled: !!runId,
refetchInterval: runId ? 4000 : false, // live tail while open
});
}
/** Gracefully cancel a queued/running run (not a raw kill). */
export function useCancelRun() {
const qc = useQueryClient();
return useMutation({
mutationFn: (runId: string) =>
apiRequest(`/api/operations/agents/runs/${runId}/cancel`, { method: "POST" }),
onSuccess: () => {
toast.success("בקשת עצירה נשלחה");
qc.invalidateQueries({ queryKey: ["operations", "agents"] });
},
onError: (e) => toast.error(`העצירה נכשלה: ${String(e)}`),
});
}
/** Reset a wedged agent session so its next wakeup starts clean. */
export function useResetAgentSession() {
const qc = useQueryClient();
return useMutation({
mutationFn: (agentId: string) =>
apiRequest(`/api/operations/agents/${agentId}/reset-session`, { method: "POST" }),
onSuccess: () => {
toast.success("ה-session אופס");
qc.invalidateQueries({ queryKey: ["operations", "agents"] });
},
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"] });
},
});
}