refactor: fix architectural issues across frontend and backend
Address all architecture review findings: P0 fixes: - Add API key authentication for admin endpoints (analytics, replay, openapi) and WebSocket connections via ADMIN_API_KEY env var - Add PostgreSQL-backed PgSessionManager and PgInterruptManager for multi-worker production deployments (in-memory defaults preserved) P1 fixes: - Implement actual tool generation in OpenAPI approve_job endpoint using generate_tool_code() and generate_agent_yaml() - Add missing clarification, interrupt_expired, and tool_result message handlers in frontend ChatPage P2 fixes: - Replace monkey-patching on CompiledStateGraph with typed GraphContext - Replace 9-param dispatch_message with WebSocketContext dataclass - Extract duplicate _envelope() into shared app/api_utils.py - Replace mutable module-level counter with crypto.randomUUID() - Remove hardcoded mock data from ReviewPage, use api.ts wrappers - Remove `as any` type escape from ReplayPage All 516 tests passing, 0 TypeScript errors.
This commit is contained in:
@@ -101,3 +101,74 @@ export async function fetchReplay(
|
||||
export async function fetchAnalytics(range = "7d"): Promise<AnalyticsData> {
|
||||
return apiFetch<AnalyticsData>(`/api/analytics?range=${range}`);
|
||||
}
|
||||
|
||||
// -- OpenAPI import --
|
||||
|
||||
export interface ImportJobResponse {
|
||||
job_id: string;
|
||||
status: string;
|
||||
spec_url: string;
|
||||
total_endpoints: number;
|
||||
classified_count: number;
|
||||
error_message: string | null;
|
||||
generated_tools_count?: number;
|
||||
}
|
||||
|
||||
export interface EndpointClassification {
|
||||
index: number;
|
||||
access_type: string;
|
||||
needs_interrupt: boolean;
|
||||
agent_group: string;
|
||||
confidence: number;
|
||||
customer_params: string[];
|
||||
endpoint: {
|
||||
path: string;
|
||||
method: string;
|
||||
operation_id: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function startImport(url: string): Promise<ImportJobResponse> {
|
||||
return apiPost<ImportJobResponse>("/api/openapi/import", { url });
|
||||
}
|
||||
|
||||
export async function fetchImportJob(jobId: string): Promise<ImportJobResponse> {
|
||||
const res = await fetch(`${API_BASE}/api/openapi/jobs/${encodeURIComponent(jobId)}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchClassifications(
|
||||
jobId: string
|
||||
): Promise<EndpointClassification[]> {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/openapi/jobs/${encodeURIComponent(jobId)}/classifications`
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function approveJob(jobId: string): Promise<ImportJobResponse> {
|
||||
return apiPost<ImportJobResponse>(
|
||||
`/api/openapi/jobs/${encodeURIComponent(jobId)}/approve`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,8 @@ import type {
|
||||
ToolAction,
|
||||
} from "../types";
|
||||
|
||||
let msgCounter = 0;
|
||||
function nextId(): string {
|
||||
msgCounter += 1;
|
||||
return `msg-${msgCounter}`;
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function ChatPage() {
|
||||
@@ -68,6 +66,48 @@ export function ChatPage() {
|
||||
setIsWaiting(false);
|
||||
break;
|
||||
}
|
||||
case "clarification": {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: nextId(),
|
||||
sender: "agent",
|
||||
agent: "System",
|
||||
content: msg.message,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
setIsWaiting(false);
|
||||
break;
|
||||
}
|
||||
case "interrupt_expired": {
|
||||
setCurrentInterrupt(null);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: nextId(),
|
||||
sender: "agent",
|
||||
agent: "System",
|
||||
content: msg.message,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
setIsWaiting(false);
|
||||
break;
|
||||
}
|
||||
case "tool_result": {
|
||||
setToolActions((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.tool === msg.tool && last.agent === msg.agent) {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...last, result: msg.result },
|
||||
];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "message_complete": {
|
||||
setMessages((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
|
||||
@@ -81,7 +81,7 @@ export function ReplayPage() {
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="section-card" style={{ padding: "2rem" }}>
|
||||
<ReplayTimeline steps={steps as any} />
|
||||
<ReplayTimeline steps={steps} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
approveJob,
|
||||
fetchClassifications,
|
||||
fetchImportJob,
|
||||
startImport,
|
||||
type EndpointClassification,
|
||||
type ImportJobResponse,
|
||||
} from "../api";
|
||||
|
||||
interface ImportJob {
|
||||
job_id: string;
|
||||
status: "pending" | "processing" | "done" | "failed";
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
interface EndpointClassification {
|
||||
interface FlatClassification {
|
||||
path: string;
|
||||
method: string;
|
||||
summary: string;
|
||||
@@ -14,48 +16,23 @@ interface EndpointClassification {
|
||||
agent_group: string;
|
||||
}
|
||||
|
||||
function flattenClassification(c: EndpointClassification): FlatClassification {
|
||||
return {
|
||||
path: c.endpoint?.path ?? "",
|
||||
method: c.endpoint?.method ?? "",
|
||||
summary: c.endpoint?.summary ?? "",
|
||||
access_type: c.access_type ?? "read",
|
||||
agent_group: c.agent_group ?? "Unassigned",
|
||||
};
|
||||
}
|
||||
|
||||
export function ReviewPage() {
|
||||
const [url, setUrl] = useState("");
|
||||
const [job, setJob] = useState<ImportJob | null>(null);
|
||||
const [job, setJob] = useState<ImportJobResponse | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [classifications, setClassifications] = useState<EndpointClassification[]>([
|
||||
{
|
||||
path: "/api/v1/orders/{order_id}/cancel",
|
||||
method: "post",
|
||||
summary: "Cancel an active Shopify order",
|
||||
access_type: "write",
|
||||
agent_group: "Order Specialist",
|
||||
},
|
||||
{
|
||||
path: "/api/v1/orders/{order_id}",
|
||||
method: "get",
|
||||
summary: "Retrieve detailed information about an order",
|
||||
access_type: "read",
|
||||
agent_group: "Order Specialist",
|
||||
},
|
||||
{
|
||||
path: "/api/v1/payments/{charge_id}/refund",
|
||||
method: "post",
|
||||
summary: "Issue a full or partial refund for a charge",
|
||||
access_type: "write",
|
||||
agent_group: "Billing Assistant",
|
||||
},
|
||||
{
|
||||
path: "/api/v1/customers/{email}/discounts",
|
||||
method: "post",
|
||||
summary: "Apply a loyalty discount to a customer account",
|
||||
access_type: "write",
|
||||
agent_group: "Billing Assistant",
|
||||
},
|
||||
{
|
||||
path: "/api/v1/inventory/check",
|
||||
method: "get",
|
||||
summary: "Query realtime stock levels across warehouses",
|
||||
access_type: "read",
|
||||
agent_group: "Unassigned",
|
||||
}
|
||||
]);
|
||||
const [approveStatus, setApproveStatus] = useState<string | null>(null);
|
||||
const [classifications, setClassifications] = useState<FlatClassification[]>([]);
|
||||
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,25 +42,13 @@ export function ReviewPage() {
|
||||
}, []);
|
||||
|
||||
function pollJob(jobId: string) {
|
||||
fetch(`/api/openapi/jobs/${encodeURIComponent(jobId)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const j: ImportJob = data.data ?? data;
|
||||
fetchImportJob(jobId)
|
||||
.then((j) => {
|
||||
setJob(j);
|
||||
if (j.status === "done") {
|
||||
return fetch(`/api/openapi/jobs/${encodeURIComponent(jobId)}/classifications`)
|
||||
.then((r) => r.json())
|
||||
.then((clfs: EndpointClassification[]) => {
|
||||
setClassifications(
|
||||
clfs.map((c: any) => ({
|
||||
path: c.endpoint?.path ?? c.path ?? "",
|
||||
method: c.endpoint?.method ?? c.method ?? "",
|
||||
summary: c.endpoint?.summary ?? c.summary ?? "",
|
||||
access_type: c.access_type ?? "read",
|
||||
agent_group: c.agent_group ?? "Unassigned",
|
||||
}))
|
||||
);
|
||||
});
|
||||
return fetchClassifications(jobId).then((clfs) => {
|
||||
setClassifications(clfs.map(flattenClassification));
|
||||
});
|
||||
} else if (j.status === "failed") {
|
||||
return;
|
||||
} else {
|
||||
@@ -100,17 +65,12 @@ export function ReviewPage() {
|
||||
if (!url.trim()) return;
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
setApproveStatus(null);
|
||||
setJob(null);
|
||||
setClassifications([]);
|
||||
|
||||
fetch("/api/openapi/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const j: ImportJob = data.data ?? data;
|
||||
startImport(url)
|
||||
.then((j) => {
|
||||
setJob(j);
|
||||
if (j.job_id) pollJob(j.job_id);
|
||||
})
|
||||
@@ -120,7 +80,7 @@ export function ReviewPage() {
|
||||
|
||||
function handleFieldChange(
|
||||
idx: number,
|
||||
field: keyof EndpointClassification,
|
||||
field: keyof FlatClassification,
|
||||
value: string
|
||||
) {
|
||||
setClassifications((prev) =>
|
||||
@@ -130,21 +90,26 @@ export function ReviewPage() {
|
||||
|
||||
function handleApprove() {
|
||||
if (!job?.job_id) return;
|
||||
fetch(`/api/openapi/jobs/${encodeURIComponent(job.job_id)}/approve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoints: classifications }),
|
||||
}).then(() => {
|
||||
alert("Approved and saved.");
|
||||
});
|
||||
setApproveStatus(null);
|
||||
approveJob(job.job_id)
|
||||
.then((result) => {
|
||||
setJob(result);
|
||||
setApproveStatus(
|
||||
`Configuration saved. ${result.generated_tools_count ?? 0} tools generated.`
|
||||
);
|
||||
})
|
||||
.catch((err: Error) => setApproveStatus(`Error: ${err.message}`));
|
||||
}
|
||||
|
||||
const groupedByAgent = classifications.reduce((acc, c, idx) => {
|
||||
const group = c.agent_group || "Unassigned";
|
||||
if (!acc[group]) acc[group] = [];
|
||||
acc[group].push({ ...c, originalIdx: idx });
|
||||
return acc;
|
||||
}, {} as Record<string, (EndpointClassification & { originalIdx: number })[]>);
|
||||
const groupedByAgent = classifications.reduce(
|
||||
(acc, c, idx) => {
|
||||
const group = c.agent_group || "Unassigned";
|
||||
if (!acc[group]) acc[group] = [];
|
||||
acc[group].push({ ...c, originalIdx: idx });
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, (FlatClassification & { originalIdx: number })[]>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
@@ -167,35 +132,105 @@ export function ReviewPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{submitError && <div style={{ color: "var(--brand-accent)", marginBottom: "1rem" }}>Error: {submitError}</div>}
|
||||
{submitError && (
|
||||
<div style={{ color: "var(--brand-accent)", marginBottom: "1rem" }}>
|
||||
Error: {submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job && (
|
||||
<div style={{ padding: "1rem", background: "var(--bg-surface)", border: "1px solid var(--border-light)", borderRadius: "var(--radius-md)", marginBottom: "1.5rem" }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border-light)",
|
||||
borderRadius: "var(--radius-md)",
|
||||
marginBottom: "1.5rem",
|
||||
}}
|
||||
>
|
||||
<strong>Job:</strong> {job.job_id} — Status:{" "}
|
||||
<span style={{ fontWeight: 600, color: job.status === "done" ? "#10b981" : job.status === "failed" ? "var(--brand-accent)" : "#f59e0b" }}>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color:
|
||||
job.status === "done" || job.status === "approved"
|
||||
? "#10b981"
|
||||
: job.status === "failed"
|
||||
? "var(--brand-accent)"
|
||||
: "#f59e0b",
|
||||
}}
|
||||
>
|
||||
{job.status}
|
||||
</span>
|
||||
{job.error_message && <div style={{ marginTop: "4px", color: "var(--brand-accent)" }}>{job.error_message}</div>}
|
||||
{job.error_message && (
|
||||
<div style={{ marginTop: "4px", color: "var(--brand-accent)" }}>
|
||||
{job.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{approveStatus && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
background: approveStatus.startsWith("Error")
|
||||
? "#fef2f2"
|
||||
: "#f0fdf4",
|
||||
border: `1px solid ${approveStatus.startsWith("Error") ? "#fecaca" : "#bbf7d0"}`,
|
||||
borderRadius: "var(--radius-md)",
|
||||
marginBottom: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{approveStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{classifications.length > 0 && (
|
||||
<>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: "1.25rem", color: "var(--text-primary)" }}>Assigned Capabilities ({classifications.length})</h3>
|
||||
<p style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem", color: "var(--text-secondary)" }}>Grouped by target Agent.</p>
|
||||
</div>
|
||||
<button onClick={handleApprove} className="btn btn-primary">
|
||||
Save Configuration
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "1.25rem",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Assigned Capabilities ({classifications.length})
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "0.25rem 0 0 0",
|
||||
fontSize: "0.875rem",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
Grouped by target Agent.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={handleApprove} className="btn btn-primary">
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="agent-grid">
|
||||
{Object.entries(groupedByAgent).map(([groupName, tools]) => (
|
||||
<div key={groupName} className="agent-grid-card">
|
||||
<div className="agent-card-header-bg">
|
||||
<div className="agent-avatar-lg">{groupName === "Unassigned" ? "?" : groupName.charAt(0).toUpperCase()}</div>
|
||||
<div className="agent-avatar-lg">
|
||||
{groupName === "Unassigned"
|
||||
? "?"
|
||||
: groupName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="agent-card-meta">
|
||||
<h3>{groupName}</h3>
|
||||
<span>{tools.length} Attached Tools</span>
|
||||
@@ -205,16 +240,36 @@ export function ReviewPage() {
|
||||
{tools.map((t) => (
|
||||
<div key={t.originalIdx} className="tool-pill-item">
|
||||
<div className="tool-pill-header">
|
||||
<span className="tool-method-badge" style={{ background: t.method === "get" ? "#3b82f6" : t.method === "post" ? "#10b981" : t.method === "delete" ? "#ef4444" : "#f59e0b" }}>
|
||||
<span
|
||||
className="tool-method-badge"
|
||||
style={{
|
||||
background:
|
||||
t.method === "get"
|
||||
? "#3b82f6"
|
||||
: t.method === "post"
|
||||
? "#10b981"
|
||||
: t.method === "delete"
|
||||
? "#ef4444"
|
||||
: "#f59e0b",
|
||||
}}
|
||||
>
|
||||
{t.method}
|
||||
</span>
|
||||
<span className="tool-path-text" title={t.path}>{t.path}</span>
|
||||
<span className="tool-path-text" title={t.path}>
|
||||
{t.path}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tool-summary-text">{t.summary}</div>
|
||||
<div className="tool-pill-controls">
|
||||
<select
|
||||
value={t.access_type}
|
||||
onChange={(e) => handleFieldChange(t.originalIdx, "access_type", e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleFieldChange(
|
||||
t.originalIdx,
|
||||
"access_type",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
className="tool-select"
|
||||
>
|
||||
<option value="read">Read Only</option>
|
||||
@@ -223,7 +278,13 @@ export function ReviewPage() {
|
||||
<input
|
||||
type="text"
|
||||
value={t.agent_group}
|
||||
onChange={(e) => handleFieldChange(t.originalIdx, "agent_group", e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleFieldChange(
|
||||
t.originalIdx,
|
||||
"agent_group",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
className="tool-input"
|
||||
placeholder="Agent Name"
|
||||
/>
|
||||
|
||||
@@ -39,13 +39,28 @@ export interface ErrorMessage {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ClarificationMessage {
|
||||
type: "clarification";
|
||||
thread_id: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface InterruptExpiredMessage {
|
||||
type: "interrupt_expired";
|
||||
thread_id: string;
|
||||
action: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type ServerMessage =
|
||||
| TokenMessage
|
||||
| InterruptMessage
|
||||
| ToolCallMessage
|
||||
| ToolResultMessage
|
||||
| MessageCompleteMessage
|
||||
| ErrorMessage;
|
||||
| ErrorMessage
|
||||
| ClarificationMessage
|
||||
| InterruptExpiredMessage;
|
||||
|
||||
// -- Client -> Server messages --
|
||||
|
||||
|
||||
Reference in New Issue
Block a user