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:
Yaojia Wang
2026-04-06 15:59:14 +02:00
parent b8654aa31f
commit af53111928
29 changed files with 1183 additions and 473 deletions

View File

@@ -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`,
{}
);
}

View File

@@ -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];

View File

@@ -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>
)}

View File

@@ -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} &mdash; 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"
/>

View File

@@ -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 --