הטמעת מוקאפ 02e (מאושר Claude-Design X17) — נראות שרת-הצד שנבנתה כ-backend: - backend: get_recent_escalations() ב-paperclip_client — משחזר הסלמות מהערות author_type='system' (severity+reason מנותחים מהגוף, identifier מ-join); Port pc_get_recent_escalations (read-only); endpoint GET /api/operations/agents/escalations. - frontend: useAgentHealth + useRecentEscalations (טיפוסים מקומיים כמו שאר operations.ts) + AgentHealthPanel ב-/operations תחת "בריאות-הסוכנים": 4 stat-tiles (זומבי/תקוע/עובד/רגוע), שורות-בעיה worst-first, וקארד הסלמות-אוטומטיות-אחרונות (severity+תיק+סיבה+מתי). דיוק מול המוקאפ: issue מוסלם עובר לבעלות-חיים→יוצא מרשימת-הבריאות, לכן שורות-הבריאות = זומבים טרם-הסלמה וקארד-ההסלמות = מטופלים (משלימים). Invariants: G12 (fetch מהמעטפת, המגע מהשער; UI צורך endpoint דומייני), G2 (מקור-בריאות/הסלמות יחיד). שער-עיצוב: מוקאפ 02e אושר לפני קוד (feedback_claude_design_gate). בדיקות: 13 pytest (2 חדשות ל-get_recent_escalations: parse severity/reason, fallback). frontend: tsc --noEmit + eslint נקיים (build מלא ב-CI/deploy — symlink node_modules שובר next build ב-worktree). api:types לא נדרש (operations.ts טיפוסים ידניים); ה-endpoint נפרס אטומית עם ה-UI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
326 lines
11 KiB
TypeScript
326 lines
11 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,
|
|
});
|
|
}
|
|
|
|
// ── Agent health taxonomy (#222) + recent escalations (#218) ───────────────
|
|
export type AgentHealthState = "zombie" | "stalled" | "working" | "idle";
|
|
|
|
export type AgentHealthItem = {
|
|
issue_id: string;
|
|
identifier: string;
|
|
status: string;
|
|
agent_id: string;
|
|
agent_name: string;
|
|
health: AgentHealthState;
|
|
wakeups: number;
|
|
recovery_wakeups: number;
|
|
};
|
|
|
|
export type AgentHealthResponse = {
|
|
ok: boolean;
|
|
items: AgentHealthItem[]; // worst-first (zombie → stalled → working → idle)
|
|
counts: Record<AgentHealthState, number>;
|
|
};
|
|
|
|
/** Per-issue agent health — surfaces recovery-loop zombies automatically. */
|
|
export function useAgentHealth() {
|
|
return useQuery({
|
|
queryKey: ["operations", "agents", "health"],
|
|
queryFn: ({ signal }) =>
|
|
apiRequest<AgentHealthResponse>("/api/operations/agents/health", { signal }),
|
|
refetchInterval: 5000, // live alongside the running-agents view
|
|
staleTime: 3000,
|
|
});
|
|
}
|
|
|
|
export type EscalationItem = {
|
|
issue_id: string;
|
|
identifier: string;
|
|
severity: "critical" | "high" | "medium";
|
|
reason: string;
|
|
created_at: string | null;
|
|
};
|
|
|
|
export type RecentEscalationsResponse = {
|
|
ok: boolean;
|
|
items: EscalationItem[]; // newest-first
|
|
};
|
|
|
|
/** Recent chair escalations (watchdog + manual) — the handled-loop history. */
|
|
export function useRecentEscalations() {
|
|
return useQuery({
|
|
queryKey: ["operations", "agents", "escalations"],
|
|
queryFn: ({ signal }) =>
|
|
apiRequest<RecentEscalationsResponse>("/api/operations/agents/escalations", { signal }),
|
|
refetchInterval: 15000, // escalations change slowly (watchdog runs every 15m)
|
|
staleTime: 10000,
|
|
});
|
|
}
|
|
|
|
/** 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 / codex_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"] });
|
|
},
|
|
});
|
|
}
|