Skip to content
33 changes: 21 additions & 12 deletions apps/dashboard/src/components/agents/AgentsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Badge } from '../ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '../ui/dialog';
import { Bot, Save, Pencil, MessageSquare, User, Trash2, Cpu, Globe, Key, Thermometer, TrendingUp } from 'lucide-react';
import { connectAgent, listAgents, listParticipants, createParticipant, updateParticipant, assignPolicy, deleteParticipant } from '../../lib/api';
import { safeParseJSON } from '../../lib/safeJson';

const CHAT_ADAPTERS = [
{ value: '', label: 'None' },
Expand Down Expand Up @@ -51,21 +52,23 @@ export function AgentsPanel({ boardId, workflowNodes = [] }: AgentsPanelProps) {

const [editingParticipant, setEditingParticipant] = useState<string | null>(null);
const [editDraft, setEditDraft] = useState<Record<string, any>>({});
const [apiError, setApiError] = useState<string | null>(null);

function parseMetadata(p: any): Record<string, any> {
if (typeof p.metadata === 'object' && p.metadata !== null) return p.metadata;
try {
return JSON.parse(p.metadata_json || p.metadata || '{}');
} catch {
return {};
}
// Single guard covers both branches: only return the value if it's a plain object.
// Arrays, primitives, and parsed-to-null all fall through to {}.
const candidate = (typeof p.metadata === 'object' && p.metadata !== null)
? p.metadata
: safeParseJSON(p.metadata_json || p.metadata || '{}', {} as Record<string, any>, 'AgentsPanel.parseMetadata');
return (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) ? candidate : {};
}

async function refresh() {
setApiError(null);
try {
const p = await listParticipants(boardId);
setParticipants(p.participants || []);
} catch {}
} catch (e: any) { setApiError(e?.message || 'Failed to load participants'); }
}

useEffect(() => { refresh(); }, [boardId]);
Expand All @@ -80,6 +83,7 @@ export function AgentsPanel({ boardId, workflowNodes = [] }: AgentsPanelProps) {

async function handleAddInternal() {
if (!internalForm.name.trim()) return;
setApiError(null);
try {
await createParticipant({
boardId,
Expand All @@ -98,11 +102,12 @@ export function AgentsPanel({ boardId, workflowNodes = [] }: AgentsPanelProps) {
await assignPolicy({ boardId, policyId: 'default', participants: [internalForm.name.trim()], weightingMode: 'hybrid', quorum: 0.6 });
setShowAddAgent(false);
await refresh();
} catch {}
} catch (e: any) { setApiError(e?.message || 'Failed to add internal agent'); }
}

async function handleAddExternal() {
if (!externalForm.name.trim()) return;
setApiError(null);
try {
const r = await connectAgent({
name: externalForm.name.trim(),
Expand All @@ -126,11 +131,12 @@ export function AgentsPanel({ boardId, workflowNodes = [] }: AgentsPanelProps) {
});
await assignPolicy({ boardId, policyId: 'default', participants: [externalForm.name.trim()], weightingMode: 'hybrid', quorum: 0.6 });
await refresh();
} catch {}
} catch (e: any) { setApiError(e?.message || 'Failed to add external agent'); }
}

async function handleAddHuman() {
if (!humanName.trim()) return;
setApiError(null);
try {
await createParticipant({
boardId,
Expand All @@ -143,7 +149,7 @@ export function AgentsPanel({ boardId, workflowNodes = [] }: AgentsPanelProps) {
setHumanName('');
setShowAddHuman(false);
await refresh();
} catch {}
} catch (e: any) { setApiError(e?.message || 'Failed to add human participant'); }
}

function startEdit(p: any) {
Expand All @@ -164,6 +170,7 @@ export function AgentsPanel({ boardId, workflowNodes = [] }: AgentsPanelProps) {
}

async function saveEdit(id: string) {
setApiError(null);
try {
const isInternal = editDraft.agentType === 'internal';
const metadata: Record<string, any> = { agentType: editDraft.agentType || '' };
Expand All @@ -183,15 +190,16 @@ export function AgentsPanel({ boardId, workflowNodes = [] }: AgentsPanelProps) {
});
setEditingParticipant(null);
await refresh();
} catch {}
} catch (e: any) { setApiError(e?.message || 'Failed to save participant'); }
}

async function handleDeleteParticipant(id: string) {
setApiError(null);
try {
await deleteParticipant(id);
setEditingParticipant(null);
await refresh();
} catch {}
} catch (e: any) { setApiError(e?.message || 'Failed to delete participant'); }
}

const humanParticipants = participants.filter(p => p.subject_type === 'human');
Expand All @@ -215,6 +223,7 @@ export function AgentsPanel({ boardId, workflowNodes = [] }: AgentsPanelProps) {
</div>
</CardHeader>
<CardContent className="space-y-4 flex-1 overflow-y-auto">
{apiError && <div className="text-xs text-red-500 mb-2">{apiError}</div>}
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<User className="h-3 w-3" /> Humans
Expand Down
12 changes: 5 additions & 7 deletions apps/dashboard/src/components/workflow/EventTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { getEvents, getRunIds, clearEvents } from '../../lib/api';
import { cn } from '../../lib/utils';
import { safeParseJSON } from '../../lib/safeJson';

const EVENT_ICONS: Record<string, React.ElementType> = {
GUARD_EVALUATED: Shield,
Expand Down Expand Up @@ -110,7 +111,7 @@ export function EventTimeline() {
return sortOrder === 'asc' ? a.seq - b.seq : b.seq - a.seq;
});
setEvents(sortedEvents);
} catch {}
} catch (err) { console.warn('[EventTimeline] Failed to fetch events:', err); }
}
load();
const t = setInterval(load, 3000);
Expand Down Expand Up @@ -249,8 +250,7 @@ export function EventTimeline() {
</thead>
<tbody className="divide-y divide-border/20">
{events.map((event: any) => {
let payload: any = {};
try { payload = event.payload_json ? JSON.parse(event.payload_json) : {}; } catch {}
const payload: any = safeParseJSON(event.payload_json, {} as any, 'EventTimeline.payload');

const summary = payload.step_label || payload.decision || payload.action || 'Completed';
const guardType = payload.guard_type || payload.guardType || '';
Expand Down Expand Up @@ -385,8 +385,7 @@ export function EventTimeline() {
{(() => {
const event = events.find(e => e.id === hoveredEvent.id);
if (!event) return null;
let payload = {};
try { payload = event.payload_json ? JSON.parse(event.payload_json) : {}; } catch {}
let payload: any = safeParseJSON(event.payload_json, {}, 'EventTimeline.payload');
const fullInfo = JSON.stringify({ seq: event.seq, run_id: event.run_id, ts: event.ts, type: event.type, ...payload }, null, 2);

return (
Expand Down Expand Up @@ -419,8 +418,7 @@ export function EventTimeline() {
{(() => {
const event = events.find(e => e.id === hoveredEvent.id);
if (!event) return null;
let payload = {};
try { payload = event.payload_json ? JSON.parse(event.payload_json) : {}; } catch {}
let payload: any = safeParseJSON(event.payload_json, {}, 'EventTimeline.payload');
return JSON.stringify({ seq: event.seq, ts: event.ts, run_id: event.run_id || null, type: event.type, ...payload }, null, 2);
})()}
</div>
Expand Down
118 changes: 118 additions & 0 deletions apps/dashboard/src/lib/__tests__/safeJson.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, afterEach } from "vitest";

// We import and re-export the helper so we can swap the DEV flag tested below.
// The actual DEV gating is tested via the exported isDev wrapper for unit tests.
import { safeParseJSON, _setDevForTesting } from "../safeJson";

afterEach(() => {
vi.restoreAllMocks();
// Restore DEV flag to the real env value after each test.
_setDevForTesting(undefined);
});

describe("safeParseJSON", () => {
it("returns the parsed value for valid JSON", () => {
const result = safeParseJSON<{ x: number }>('{"x":42}', { x: 0 });
expect(result).toEqual({ x: 42 });
});

it("returns an array parsed from valid JSON", () => {
const result = safeParseJSON<number[]>("[1,2,3]", []);
expect(result).toEqual([1, 2, 3]);
});

it("returns the fallback for malformed JSON", () => {
_setDevForTesting(false);
const result = safeParseJSON<string[]>("{bad json}", []);
expect(result).toEqual([]);
});

it("returns the fallback for null input (no warn)", () => {
_setDevForTesting(true);
const warnSpy = vi.spyOn(console, "warn");
const result = safeParseJSON<number>(null, 99);
expect(result).toBe(99);
expect(warnSpy).not.toHaveBeenCalled();
});

it("returns the fallback for undefined input (no warn)", () => {
_setDevForTesting(true);
const warnSpy = vi.spyOn(console, "warn");
const result = safeParseJSON<number>(undefined, 99);
expect(result).toBe(99);
expect(warnSpy).not.toHaveBeenCalled();
});

it("returns the fallback for empty string input (no warn)", () => {
_setDevForTesting(true);
const warnSpy = vi.spyOn(console, "warn");
const result = safeParseJSON<number>("", 99);
expect(result).toBe(99);
expect(warnSpy).not.toHaveBeenCalled();
});

it("warns in DEV mode when JSON is malformed", () => {
_setDevForTesting(true);
const warnSpy = vi.spyOn(console, "warn");
safeParseJSON<null>("{bad}", null, "myContext");
expect(warnSpy).toHaveBeenCalledOnce();
const [msg] = warnSpy.mock.calls[0];
expect(msg).toContain("myContext");
});

it("does NOT warn in production mode when JSON is malformed", () => {
_setDevForTesting(false);
const warnSpy = vi.spyOn(console, "warn");
safeParseJSON<null>("{bad}", null, "myContext");
expect(warnSpy).not.toHaveBeenCalled();
});

it("includes context label in warn message when provided", () => {
_setDevForTesting(true);
const warnSpy = vi.spyOn(console, "warn");
safeParseJSON("{broken}", null, "MyLabel");
expect(warnSpy).toHaveBeenCalledOnce();
expect(warnSpy.mock.calls[0][0]).toContain("[MyLabel]");
});

it("warning never includes raw input content (no token/PII leak)", () => {
_setDevForTesting(true);
const warnSpy = vi.spyOn(console, "warn");
const sensitiveInput = '{"api_key":"sk-ant-api03-SECRET_TOKEN_XYZ"';
safeParseJSON(sensitiveInput, null);
expect(warnSpy).toHaveBeenCalledOnce();
const msg: string = warnSpy.mock.calls[0][0];
expect(msg).not.toContain("api_key");
expect(msg).not.toContain("sk-ant");
expect(msg).not.toContain("SECRET");
});

it("warning reports input length only", () => {
_setDevForTesting(true);
const warnSpy = vi.spyOn(console, "warn");
const longInput = "{" + "a".repeat(200);
safeParseJSON(longInput, null);
expect(warnSpy).toHaveBeenCalledOnce();
const msg: string = warnSpy.mock.calls[0][0];
expect(msg).toContain(`length=${longInput.length}`);
});

// Valid-but-falsy JSON values must round-trip, NOT be replaced with the fallback.
// A future refactor that uses `if (!result) return fallback` would silently break
// these — pin the contract.
it("returns the parsed value 'false' (not the fallback)", () => {
expect(safeParseJSON<boolean | string>("false", "FALLBACK")).toBe(false);
});

it("returns the parsed value '0' (not the fallback)", () => {
expect(safeParseJSON<number>("0", 99)).toBe(0);
});

it("returns the parsed value 'null' (not the fallback)", () => {
expect(safeParseJSON<unknown>("null", "FALLBACK")).toBeNull();
});

it("returns the parsed value '\"\"' (the empty string, not the fallback)", () => {
expect(safeParseJSON<string>('""', "FALLBACK")).toBe("");
});
});
57 changes: 57 additions & 0 deletions apps/dashboard/src/lib/safeJson.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Safely parses a JSON string, returning a typed fallback value on failure.
*
* @param input - The raw JSON string (or null/undefined).
* @param fallback - Value returned when input is empty or unparseable.
* @param context - Optional label used in dev-mode console warnings.
* @returns The parsed value cast to T, or `fallback`.
*/

// Minimal augmentation so TypeScript recognises import.meta.env.DEV without
// pulling in the full vite/client types (which conflict with workspace Vite v7).
declare global {
interface ImportMeta {
readonly env: { readonly DEV: boolean };
}
}

// Internal override used only by unit tests to simulate DEV/prod mode.
// In production code this is always `undefined` and the real import.meta.env.DEV is used.
let _devOverride: boolean | undefined;

/**
* @internal – only for unit tests. Becomes a no-op in production builds so the
* dev-mode override cannot be flipped at runtime by injected/3rd-party code.
*/
export const _setDevForTesting: (value: boolean | undefined) => void =
import.meta.env.DEV
? (value) => { _devOverride = value; }
: (_value) => { /* no-op in production */ };

function isDevMode(): boolean {
if (_devOverride !== undefined) return _devOverride;
return import.meta.env.DEV;
}

export function safeParseJSON<T>(
input: string | null | undefined,
fallback: T,
context?: string,
): T {
if (input == null || input === "") {
return fallback;
}

try {
return JSON.parse(input) as T;
} catch (err) {
// Log structural metadata only — never the raw content, which can contain
// tokens, keys, or PII that would leak into DevTools / RUM capture.
if (isDevMode()) {
const label = context ? `[${context}] ` : "";
const len = typeof input === "string" ? input.length : -1;
console.warn(`safeParseJSON ${label}failed to parse: input length=${len}`);
}
return fallback;
}
}
15 changes: 11 additions & 4 deletions apps/dashboard/src/pages/BoardDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Badge } from '../components/ui/badge';
import { ArrowLeft, Clock, ChevronDown, ChevronUp, Users, ArrowRight, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { getBoard, getEvents, listParticipants } from '../lib/api';
import { JsonBlock } from '../components/JsonPanel';
import { safeParseJSON } from '../lib/safeJson';

const DECISION_COLORS: Record<string, string> = {
ALLOW: 'text-emerald-400 border-emerald-500/30 bg-emerald-500/10',
Expand Down Expand Up @@ -73,10 +74,16 @@ export default function BoardDetailPage() {
const map: Record<string, any> = {};
for (const e of events) {
if (e.type === 'FINAL_DECISION' && e.run_id) {
try {
const payload = JSON.parse(e.payload_json || '{}');
map[e.run_id] = payload;
} catch {}
// Distinguish empty input (legitimate, treat as `{}`) from parse failure
// (malformed payload, skip — never overwrite a previously-valid decision
// for the same run_id with an empty fallback).
const isEmpty = !e.payload_json || e.payload_json === '';
if (isEmpty) {
map[e.run_id] = {};
} else {
const parsed = safeParseJSON<any>(e.payload_json, null, 'BoardDetailPage.runDecisions');
if (parsed !== null) map[e.run_id] = parsed;
}
}
}
return map;
Expand Down
Loading
Loading