From f7d9c925ca6d65bbca54375e17689c46ca14b5a2 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Wed, 3 Jun 2026 05:25:49 -0300 Subject: [PATCH 1/3] feat: implement live-refresh for traces using engine trigger --- console/web/src/lib/traces-live.test.ts | 255 ++++++++++++++++++ console/web/src/lib/traces-live.ts | 202 ++++++++++++++ .../Traces/components/TraceGroupsView.tsx | 3 - .../src/pages/Traces/hooks/useTraceData.ts | 8 +- .../src/pages/Traces/hooks/useTraceGroups.ts | 7 +- console/web/src/pages/Traces/index.tsx | 7 +- 6 files changed, 470 insertions(+), 12 deletions(-) create mode 100644 console/web/src/lib/traces-live.test.ts create mode 100644 console/web/src/lib/traces-live.ts diff --git a/console/web/src/lib/traces-live.test.ts b/console/web/src/lib/traces-live.test.ts new file mode 100644 index 000000000..ed5e7a005 --- /dev/null +++ b/console/web/src/lib/traces-live.test.ts @@ -0,0 +1,255 @@ +import type { QueryClient } from '@tanstack/react-query' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { IIIConnectionState, IiiClient } from '@/lib/iii-client' +import { + makeTracesChangedHandler, + startTracesSubscription, +} from './traces-live' + +function fakeQueryClient() { + const invalidateQueries = vi.fn() + return { + client: { invalidateQueries } as unknown as QueryClient, + invalidateQueries, + } +} + +function fakeClient() { + const triggers: Array<{ + type: string + function_id: string + config?: unknown + }> = [] + let spanHandler: ((p: unknown) => void) | null = null + let connListener: ((s: IIIConnectionState) => void) | null = null + const offSignal = vi.fn() + const offConn = vi.fn() + const triggerUnregister = vi.fn() + + const on = vi.fn((fn: string, handler: (p: unknown) => void) => { + if (fn === 'iii::console::traces_changed') spanHandler = handler + return offSignal + }) + const registerTrigger = vi.fn( + (input: { type: string; function_id: string; config?: unknown }) => { + triggers.push(input) + return { unregister: triggerUnregister } + }, + ) + const addConnectionStateListener = vi.fn( + (handler: (s: IIIConnectionState) => void) => { + connListener = handler + return offConn + }, + ) + + const client = { + browserId: 'console-test', + on, + registerTrigger, + addConnectionStateListener, + call: vi.fn(), + dispose: vi.fn(async () => {}), + } as unknown as IiiClient + + return { + client, + on, + registerTrigger, + triggers, + offSignal, + offConn, + triggerUnregister, + fireSpan: () => spanHandler?.(undefined), + fireConn: (s: IIIConnectionState) => connListener?.(s), + } +} + +function fakeDoc(initial: 'visible' | 'hidden' = 'visible') { + let visibilityState = initial + let handler: (() => void) | null = null + const addEventListener = vi.fn((type: string, h: () => void) => { + if (type === 'visibilitychange') handler = h + }) + const removeEventListener = vi.fn() + const doc = { + get visibilityState() { + return visibilityState + }, + addEventListener, + removeEventListener, + } + return { + doc: doc as unknown as Document, + addEventListener, + removeEventListener, + setVisibility: (s: 'visible' | 'hidden') => { + visibilityState = s + }, + fireVisibilityChange: () => handler?.(), + } +} + +describe('makeTracesChangedHandler', () => { + it('invalidates both trace query keys when not paused', () => { + const { client, invalidateQueries } = fakeQueryClient() + const handler = makeTracesChangedHandler(client, { current: false }) + + handler() + + expect(invalidateQueries).toHaveBeenCalledTimes(2) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['traces'] }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['traceGroups'], + }) + }) + + it('does nothing while paused', () => { + const { client, invalidateQueries } = fakeQueryClient() + const handler = makeTracesChangedHandler(client, { current: true }) + + handler() + + expect(invalidateQueries).not.toHaveBeenCalled() + }) + + it('reads the pause flag live from the ref', () => { + const { client, invalidateQueries } = fakeQueryClient() + const ref = { current: true } + const handler = makeTracesChangedHandler(client, ref) + + handler() + expect(invalidateQueries).not.toHaveBeenCalled() + + ref.current = false + handler() + expect(invalidateQueries).toHaveBeenCalledTimes(2) + }) +}) + +describe('startTracesSubscription', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('registers the span handler and binds a trace trigger on start', () => { + const { client, on, triggers } = fakeClient() + startTracesSubscription(client, () => {}) + + expect(on).toHaveBeenCalledWith( + 'iii::console::traces_changed', + expect.any(Function), + ) + expect(triggers).toEqual([ + { + type: 'trace', + function_id: 'iii::console::traces_changed::console-test', + config: {}, + }, + ]) + }) + + it('coalesces a burst of per-span ticks into a single refetch', () => { + const { client, fireSpan } = fakeClient() + const onSignal = vi.fn() + startTracesSubscription(client, onSignal, { coalesceMs: 400 }) + + fireSpan() + fireSpan() + fireSpan() + expect(onSignal).not.toHaveBeenCalled() // still debouncing + + vi.advanceTimersByTime(400) + expect(onSignal).toHaveBeenCalledTimes(1) + }) + + it('fans out again on a span that arrives after the window flushed', () => { + const { client, fireSpan } = fakeClient() + const onSignal = vi.fn() + startTracesSubscription(client, onSignal, { coalesceMs: 400 }) + + fireSpan() + vi.advanceTimersByTime(400) + fireSpan() + vi.advanceTimersByTime(400) + + expect(onSignal).toHaveBeenCalledTimes(2) + }) + + it('re-syncs (refetches) on reconnect without re-registering the trigger', () => { + const { client, registerTrigger, fireConn } = fakeClient() + const onSignal = vi.fn() + startTracesSubscription(client, onSignal) + + fireConn('connected') + + // SDK replays the registered trigger itself — we must not double-register. + expect(registerTrigger).toHaveBeenCalledTimes(1) + expect(onSignal).toHaveBeenCalledTimes(1) + }) + + it('does not re-sync on non-connected transitions', () => { + const { client, fireConn } = fakeClient() + const onSignal = vi.fn() + startTracesSubscription(client, onSignal) + + fireConn('reconnecting') + fireConn('disconnected') + + expect(onSignal).not.toHaveBeenCalled() + }) + + it('cleans up the handler, listener, trigger, and pending timer on stop', () => { + const { client, offSignal, offConn, triggerUnregister, fireSpan } = + fakeClient() + const onSignal = vi.fn() + const stop = startTracesSubscription(client, onSignal, { coalesceMs: 400 }) + + fireSpan() // arm the debounce timer + stop() + vi.advanceTimersByTime(400) + + expect(offSignal).toHaveBeenCalledTimes(1) + expect(offConn).toHaveBeenCalledTimes(1) + expect(triggerUnregister).toHaveBeenCalledTimes(1) + expect(onSignal).not.toHaveBeenCalled() // timer was cleared + }) + + it('re-syncs when the tab becomes visible again', () => { + const { client } = fakeClient() + const doc = fakeDoc('visible') + const onSignal = vi.fn() + startTracesSubscription(client, onSignal, { doc: doc.doc }) + + doc.fireVisibilityChange() + + expect(onSignal).toHaveBeenCalledTimes(1) + }) + + it('does not re-sync on a visibilitychange that leaves the tab hidden', () => { + const { client } = fakeClient() + const doc = fakeDoc('hidden') + const onSignal = vi.fn() + startTracesSubscription(client, onSignal, { doc: doc.doc }) + + doc.fireVisibilityChange() + + expect(onSignal).not.toHaveBeenCalled() + }) + + it('removes the visibilitychange listener on stop', () => { + const { client } = fakeClient() + const doc = fakeDoc('visible') + const stop = startTracesSubscription(client, () => {}, { doc: doc.doc }) + + stop() + + expect(doc.removeEventListener).toHaveBeenCalledWith( + 'visibilitychange', + expect.any(Function), + ) + }) +}) diff --git a/console/web/src/lib/traces-live.ts b/console/web/src/lib/traces-live.ts new file mode 100644 index 000000000..fbed51c6f --- /dev/null +++ b/console/web/src/lib/traces-live.ts @@ -0,0 +1,202 @@ +/** + * Live-refresh wiring for the devtools Traces view, driven by the engine + * `trace` trigger (the iii observability worker). + * + * Every span that lands in the engine's in-memory trace store fires the + * `trace` trigger — the same client-agnostic mechanism as the `log` trigger, + * available to any iii client. This module registers a browser-local handler, + * binds a `trace` trigger to it, and — after a short trailing debounce — + * invalidates the Traces React Query caches so the page refetches on real span + * activity instead of polling `engine::traces::*` on a 3s timer. + * + * The engine fires once PER SPAN (mirroring the `log` trigger), so a busy turn + * produces a burst; the debounce collapses that burst into a single refetch. + * + * The imperative core (`startTracesSubscription`) and the pure invalidation + * handler (`makeTracesChangedHandler`) are framework-free so they unit-test + * without a DOM; `useTracesLiveRefresh` is the thin React wrapper. + */ + +import type { QueryClient } from '@tanstack/react-query' +import { useQueryClient } from '@tanstack/react-query' +import { useEffect, useRef } from 'react' +import { getIiiClient, type IiiClient } from '@/lib/iii-client' + +/** + * Browser-local function the engine `trace` trigger invokes. The `iii::` + * prefix marks it engine-internal (`is_iii_builtin_function_id`), so the spans + * produced by DELIVERING this trigger are tagged `iii.function.kind=internal` + * — hidden from the Traces view by the default `include_internal:false` query, + * and skipped by the engine's trigger loop-break. Without this, the trigger's + * own delivery calls flood the trace list with `traces_changed` spans. + */ +const TRACES_CHANGED_FN = 'iii::console::traces_changed' +/** Engine trigger type registered by the observability worker. */ +const TRACE_TRIGGER_TYPE = 'trace' +/** Trailing-edge debounce: collapse a burst of per-span ticks into one refetch. */ +const DEFAULT_COALESCE_MS = 400 + +/** Dev-only trace-stream diagnostics. Silent in production builds. */ +function dlog(msg: string, data?: unknown): void { + if (import.meta.env?.DEV) { + console.debug(`[traces-live] ${msg}`, data ?? '') + } +} + +/** + * Build the signal handler that refetches the Traces queries. Pause is read + * live from a ref so toggling pause never re-creates the subscription. We skip + * refetching while the tab is hidden to avoid background work; the + * `visibilitychange` re-sync wired up in `startTracesSubscription` catches up + * on return. (The app-wide QueryClient disables `refetchOnWindowFocus`, so the + * visibility listener — not focus — is the recovery path.) + */ +export function makeTracesChangedHandler( + qc: QueryClient, + isPausedRef: { current: boolean }, +): () => void { + return () => { + if (isPausedRef.current) { + dlog('signal ignored (paused)') + return + } + if ( + typeof document !== 'undefined' && + document.visibilityState === 'hidden' + ) { + dlog('signal ignored (tab hidden)') + return + } + dlog('signal received → invalidating traces queries') + qc.invalidateQueries({ queryKey: ['traces'] }) + qc.invalidateQueries({ queryKey: ['traceGroups'] }) + } +} + +/** + * Register a browser-local handler, bind an engine `trace` trigger to it, and + * re-sync on reconnect / tab-visible. Returns a cleanup that clears the + * debounce timer, unregisters the handler + connection listener, and + * unregisters the trigger. + * + * The iii-browser-sdk replays BOTH registered functions and registered + * triggers on reconnect (see `onSocketOpen`), so we do NOT manually + * re-register on `'connected'` — that would create a duplicate trigger. We + * only re-sync via `onSignal()` so a gap (or a cold initial fetch that raced + * the WS connect) recovers without a polling fallback. + * + * Per-span ticks are coalesced by a trailing debounce; reconnect/visibility + * re-syncs call `onSignal` directly (immediate). `onSignal` itself honors the + * pause/hidden gates, so direct re-syncs stay correct. + */ +export function startTracesSubscription( + client: Pick< + IiiClient, + 'browserId' | 'on' | 'registerTrigger' | 'addConnectionStateListener' + >, + onSignal: () => void, + opts: { + coalesceMs?: number + doc?: Pick< + Document, + 'addEventListener' | 'removeEventListener' | 'visibilityState' + > + } = {}, +): () => void { + const coalesceMs = opts.coalesceMs ?? DEFAULT_COALESCE_MS + const doc = + opts.doc ?? (typeof document !== 'undefined' ? document : undefined) + + // Trailing-edge debounce: the engine fires once per span, so collapse a + // burst into a single refetch. + let timer: ReturnType | null = null + const tick = () => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + timer = null + onSignal() + }, coalesceMs) + } + + // The engine `trace` trigger calls this browser-local function per span. + const off = client.on(TRACES_CHANGED_FN, tick) + // `on()` registers under `::`; the trigger must target that id. + const functionId = `${TRACES_CHANGED_FN}::${client.browserId}` + + const trigger = client.registerTrigger({ + type: TRACE_TRIGGER_TYPE, + function_id: functionId, + config: {}, + }) + dlog('trace trigger registered', { functionId }) + + const offConn = client.addConnectionStateListener((state) => { + dlog('connection state', state) + if (state !== 'connected') return + // Handler + trigger are auto-replayed by the SDK; just re-sync to recover + // spans that landed while the socket was down (and cold-start races). + onSignal() + }) + + let offVisibility: (() => void) | undefined + if (doc) { + const onVisible = () => { + if (doc.visibilityState !== 'visible') return + dlog('tab visible → re-syncing traces queries') + onSignal() + } + doc.addEventListener('visibilitychange', onVisible) + offVisibility = () => doc.removeEventListener('visibilitychange', onVisible) + } + + return () => { + if (timer) { + clearTimeout(timer) + timer = null + } + off() + offConn() + offVisibility?.() + try { + trigger.unregister() + } catch { + // SDK already disposed; nothing to do. + } + } +} + +/** + * Subscribe the Traces page to live span activity via the engine `trace` + * trigger for the lifetime of the component. The shared `getIiiClient()` + * singleton is NOT disposed on unmount (it's app-wide), so the explicit + * cleanup is required. + */ +export function useTracesLiveRefresh({ + isPaused, +}: { + isPaused: boolean +}): void { + const qc = useQueryClient() + const isPausedRef = useRef(isPaused) + useEffect(() => { + isPausedRef.current = isPaused + }, [isPaused]) + + useEffect(() => { + let stop: (() => void) | undefined + let disposed = false + void (async () => { + const client = await getIiiClient() + if (disposed) return + stop = startTracesSubscription( + client, + makeTracesChangedHandler(qc, isPausedRef), + ) + })() + return () => { + disposed = true + stop?.() + } + // Pause is read via the ref, so the subscription is set up once per mount. + }, [qc]) +} diff --git a/console/web/src/pages/Traces/components/TraceGroupsView.tsx b/console/web/src/pages/Traces/components/TraceGroupsView.tsx index dd09b2f64..78cbd06db 100644 --- a/console/web/src/pages/Traces/components/TraceGroupsView.tsx +++ b/console/web/src/pages/Traces/components/TraceGroupsView.tsx @@ -23,7 +23,6 @@ import { groupHeading, summarizeGroup } from '../lib/groupTraces' interface TraceGroupsViewProps { attribute: GroupByAttribute showSystem: boolean - isPaused: boolean onSelectTrace: (traceId: string) => void /** * Called with the full TraceGroup when a row is clicked. Routes that @@ -37,7 +36,6 @@ interface TraceGroupsViewProps { export function TraceGroupsView({ attribute, showSystem, - isPaused, onSelectTrace, onSelectGroup, selectedTraceId, @@ -45,7 +43,6 @@ export function TraceGroupsView({ const { groups, isLoading, unavailable } = useTraceGroups({ groupBy: attribute, includeInternal: showSystem, - isPaused, }) if (unavailable) { diff --git a/console/web/src/pages/Traces/hooks/useTraceData.ts b/console/web/src/pages/Traces/hooks/useTraceData.ts index 5c8aa4619..3fcbf8a9e 100644 --- a/console/web/src/pages/Traces/hooks/useTraceData.ts +++ b/console/web/src/pages/Traces/hooks/useTraceData.ts @@ -22,7 +22,6 @@ export interface UseTraceDataOptions { filterParams: TracesFilterParams showSystem: boolean debouncedSearch: string - isPaused: boolean } export interface UseTraceDataReturn { @@ -40,7 +39,6 @@ export function useTraceData({ filterParams, showSystem, debouncedSearch, - isPaused, }: UseTraceDataOptions): UseTraceDataReturn { const [traceGroups, setTraceListItems] = useState([]) const [hasOtelConfigured, setHasOtelConfigured] = useState(false) @@ -68,7 +66,11 @@ export function useTraceData({ limit: DEFAULT_TRACE_LIMIT, include_internal: showSystem, }), - refetchInterval: isPaused ? false : 3000, + // Live updates arrive via `useTracesLiveRefresh` (the engine `trace` + // trigger), which invalidates the ['traces'] key — no polling interval. + // Initial mount fetch + manual Refresh + signal-driven invalidation cover + // refresh; reconnect/tab-visible re-sync handles cold-start races. + refetchInterval: false, staleTime: 1000, }) diff --git a/console/web/src/pages/Traces/hooks/useTraceGroups.ts b/console/web/src/pages/Traces/hooks/useTraceGroups.ts index 642562859..2e513c9ac 100644 --- a/console/web/src/pages/Traces/hooks/useTraceGroups.ts +++ b/console/web/src/pages/Traces/hooks/useTraceGroups.ts @@ -14,8 +14,6 @@ export interface UseTraceGroupsOptions { groupBy: GroupByOption /** Include engine-internal spans. */ includeInternal: boolean - /** When true, suspend auto-refresh (matches the flat-list hook). */ - isPaused: boolean } export interface UseTraceGroupsReturn { @@ -43,7 +41,6 @@ export interface UseTraceGroupsReturn { export function useTraceGroups({ groupBy, includeInternal, - isPaused, }: UseTraceGroupsOptions): UseTraceGroupsReturn { const enabled = groupBy !== 'none' @@ -59,7 +56,9 @@ export function useTraceGroups({ limit: DEFAULT_GROUP_LIMIT, include_internal: includeInternal, }), - refetchInterval: isPaused ? false : 3000, + // Live updates arrive via `useTracesLiveRefresh` (the engine `trace` + // trigger), which invalidates the ['traceGroups'] key — no polling. + refetchInterval: false, staleTime: 1000, retry: (failureCount, err) => { // Don't retry when the endpoint is missing — the UI hides the diff --git a/console/web/src/pages/Traces/index.tsx b/console/web/src/pages/Traces/index.tsx index 884abf68b..2aaab58da 100644 --- a/console/web/src/pages/Traces/index.tsx +++ b/console/web/src/pages/Traces/index.tsx @@ -16,6 +16,7 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary' import { Pagination } from '@/components/ui/Pagination' import { Skeleton } from '@/components/ui/Skeleton' import { StatusPanel } from '@/components/ui/StatusPanel' +import { useTracesLiveRefresh } from '@/lib/traces-live' import { cn } from '@/lib/utils' import { fetchTraceTree, type TraceGroup } from './api/traces' import { FlameGraph } from './components/FlameGraph' @@ -90,9 +91,12 @@ export function Traces() { filterParams, showSystem, debouncedSearch, - isPaused, }) + // Replace polling with the engine `trace` trigger: refetch the trace queries + // when the observability worker signals new spans, suspended while paused. + useTracesLiveRefresh({ isPaused }) + const totalPages = Math.max( 1, Math.ceil(traceGroups.length / filterState.pageSize), @@ -269,7 +273,6 @@ export function Traces() { selectTrace(id)} onSelectGroup={(group) => setSelectedGroup(group)} From c74c973206ec1585a5101b2570dd970dc9fd72fc Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Thu, 4 Jun 2026 06:11:47 -0300 Subject: [PATCH 2/3] feat: enhance traces live refresh with onExtra callback for silent updates --- console/web/src/lib/traces-live.test.ts | 26 +++++++- console/web/src/lib/traces-live.ts | 27 ++++++-- console/web/src/pages/Traces/index.tsx | 65 ++++++++++++------- .../src/pages/Traces/lib/spanLabel.test.ts | 33 ++++++++-- console/web/src/pages/Traces/lib/spanLabel.ts | 15 ++++- .../web/src/pages/Traces/lib/spanTree.test.ts | 12 +++- 6 files changed, 141 insertions(+), 37 deletions(-) diff --git a/console/web/src/lib/traces-live.test.ts b/console/web/src/lib/traces-live.test.ts index ed5e7a005..c98c77a0e 100644 --- a/console/web/src/lib/traces-live.test.ts +++ b/console/web/src/lib/traces-live.test.ts @@ -33,7 +33,7 @@ function fakeClient() { const registerTrigger = vi.fn( (input: { type: string; function_id: string; config?: unknown }) => { triggers.push(input) - return { unregister: triggerUnregister } + return triggerUnregister }, ) const addConnectionStateListener = vi.fn( @@ -125,6 +125,30 @@ describe('makeTracesChangedHandler', () => { handler() expect(invalidateQueries).toHaveBeenCalledTimes(2) }) + + it('runs the onExtra callback when not paused (e.g. reload open trace detail)', () => { + const { client } = fakeQueryClient() + const onExtra = vi.fn() + const handler = makeTracesChangedHandler( + client, + { current: false }, + onExtra, + ) + + handler() + + expect(onExtra).toHaveBeenCalledTimes(1) + }) + + it('skips onExtra while paused', () => { + const { client } = fakeQueryClient() + const onExtra = vi.fn() + const handler = makeTracesChangedHandler(client, { current: true }, onExtra) + + handler() + + expect(onExtra).not.toHaveBeenCalled() + }) }) describe('startTracesSubscription', () => { diff --git a/console/web/src/lib/traces-live.ts b/console/web/src/lib/traces-live.ts index fbed51c6f..93ade66c5 100644 --- a/console/web/src/lib/traces-live.ts +++ b/console/web/src/lib/traces-live.ts @@ -54,6 +54,7 @@ function dlog(msg: string, data?: unknown): void { export function makeTracesChangedHandler( qc: QueryClient, isPausedRef: { current: boolean }, + onExtra?: () => void, ): () => void { return () => { if (isPausedRef.current) { @@ -70,6 +71,10 @@ export function makeTracesChangedHandler( dlog('signal received → invalidating traces queries') qc.invalidateQueries({ queryKey: ['traces'] }) qc.invalidateQueries({ queryKey: ['traceGroups'] }) + // Extra refresh hook — e.g. silently reload the open trace's detail tree, + // which isn't a React Query cache and so isn't covered by the invalidations + // above. Shares the pause/hidden gating. + onExtra?.() } } @@ -123,7 +128,7 @@ export function startTracesSubscription( // `on()` registers under `::`; the trigger must target that id. const functionId = `${TRACES_CHANGED_FN}::${client.browserId}` - const trigger = client.registerTrigger({ + const offTrigger = client.registerTrigger({ type: TRACE_TRIGGER_TYPE, function_id: functionId, config: {}, @@ -158,7 +163,7 @@ export function startTracesSubscription( offConn() offVisibility?.() try { - trigger.unregister() + offTrigger() } catch { // SDK already disposed; nothing to do. } @@ -170,17 +175,28 @@ export function startTracesSubscription( * trigger for the lifetime of the component. The shared `getIiiClient()` * singleton is NOT disposed on unmount (it's app-wide), so the explicit * cleanup is required. + * + * `onSignal` runs on each (non-paused, visible) signal alongside the list + * refetch — the page uses it to silently reload the open trace's detail tree, + * which is fetched imperatively (not a React Query cache) and so isn't covered + * by the query invalidations. Read live from a ref so it never re-subscribes. */ export function useTracesLiveRefresh({ isPaused, + onSignal, }: { isPaused: boolean + onSignal?: () => void }): void { const qc = useQueryClient() const isPausedRef = useRef(isPaused) + const onSignalRef = useRef(onSignal) useEffect(() => { isPausedRef.current = isPaused }, [isPaused]) + useEffect(() => { + onSignalRef.current = onSignal + }, [onSignal]) useEffect(() => { let stop: (() => void) | undefined @@ -190,13 +206,16 @@ export function useTracesLiveRefresh({ if (disposed) return stop = startTracesSubscription( client, - makeTracesChangedHandler(qc, isPausedRef), + makeTracesChangedHandler(qc, isPausedRef, () => + onSignalRef.current?.(), + ), ) })() return () => { disposed = true stop?.() } - // Pause is read via the ref, so the subscription is set up once per mount. + // Pause + onSignal are read via refs, so the subscription is set up once + // per mount. }, [qc]) } diff --git a/console/web/src/pages/Traces/index.tsx b/console/web/src/pages/Traces/index.tsx index 2aaab58da..641f37017 100644 --- a/console/web/src/pages/Traces/index.tsx +++ b/console/web/src/pages/Traces/index.tsx @@ -93,10 +93,6 @@ export function Traces() { debouncedSearch, }) - // Replace polling with the engine `trace` trigger: refetch the trace queries - // when the observability worker signals new spans, suspended while paused. - useTracesLiveRefresh({ isPaused }) - const totalPages = Math.max( 1, Math.ceil(traceGroups.length / filterState.pageSize), @@ -141,25 +137,49 @@ export function Traces() { containerRef, }) - const loadTraceSpans = useCallback(async (traceId: string) => { - setIsLoadingSpans(true) - setSpansError(null) - setWaterfallData(null) - try { - const data = await fetchTraceTree(traceId) - if (data.roots?.length) { - const wf = treeToWaterfallData(data.roots) - if (wf) setWaterfallData(wf) - else setSpansError('failed to process span data') - } else { - setSpansError('no span data available for this trace') + // `silent` reload (used by the live-refresh signal) updates the waterfall in + // place without the loading spinner / blank-out / error states, so the open + // trace's detail streams in new spans without flicker. A transient empty or + // failed read is ignored, keeping the current view rather than clearing it. + const loadTraceSpans = useCallback( + async (traceId: string, opts?: { silent?: boolean }) => { + const silent = opts?.silent ?? false + if (!silent) { + setIsLoadingSpans(true) + setSpansError(null) + setWaterfallData(null) } - } catch (err) { - setSpansError(err instanceof Error ? err.message : 'failed to load trace') - } finally { - setIsLoadingSpans(false) - } - }, []) + try { + const data = await fetchTraceTree(traceId) + if (data.roots?.length) { + const wf = treeToWaterfallData(data.roots) + if (wf) setWaterfallData(wf) + else if (!silent) setSpansError('failed to process span data') + } else if (!silent) { + setSpansError('no span data available for this trace') + } + } catch (err) { + if (!silent) { + setSpansError( + err instanceof Error ? err.message : 'failed to load trace', + ) + } + } finally { + if (!silent) setIsLoadingSpans(false) + } + }, + [], + ) + + // Live-refresh: refetch the trace list on the engine `trace` trigger, and + // silently reload the open trace's detail tree so it streams new spans + // without a reselect. Suspended while paused / tab hidden (see the hook). + useTracesLiveRefresh({ + isPaused, + onSignal: () => { + if (selectedTraceId) loadTraceSpans(selectedTraceId, { silent: true }) + }, + }) const selectTrace = useCallback( (traceId: string | null) => { @@ -168,7 +188,6 @@ export function Traces() { setWaterfallData(null) setSpansError(null) if (traceId) { - setIsPaused(true) loadTraceSpans(traceId) } }, diff --git a/console/web/src/pages/Traces/lib/spanLabel.test.ts b/console/web/src/pages/Traces/lib/spanLabel.test.ts index 232f4bc65..251f8cceb 100644 --- a/console/web/src/pages/Traces/lib/spanLabel.test.ts +++ b/console/web/src/pages/Traces/lib/spanLabel.test.ts @@ -109,18 +109,26 @@ describe('formatSpanLabel', () => { }) describe('isEngineRoutingSpan', () => { + // Engine routing spans carry a `function_id` attribute (set by the engine's + // invocation instrumentation). The verb prefix alone is NOT enough — the + // harness SDK emits its own `call ` span without that attribute. it('matches `handle_invocation X` regardless of service_name', () => { expect( isEngineRoutingSpan({ name: 'handle_invocation fn-foo', service_name: 'iii', + attributes: { function_id: 'fn-foo' }, }), ).toBe(true) }) it('matches `call X` regardless of service_name', () => { expect( - isEngineRoutingSpan({ name: 'call fn-foo', service_name: 'iii' }), + isEngineRoutingSpan({ + name: 'call fn-foo', + service_name: 'iii', + attributes: { function_id: 'fn-foo' }, + }), ).toBe(true) }) @@ -131,26 +139,39 @@ describe('isEngineRoutingSpan', () => { isEngineRoutingSpan({ name: 'handle_invocation fn-foo', service_name: 'iii-engine', + attributes: { function_id: 'fn-foo' }, }), ).toBe(true) expect( isEngineRoutingSpan({ name: 'call fn-foo', service_name: 'engine', + attributes: { function_id: 'fn-foo' }, }), ).toBe(true) }) - it('does NOT match a non-routing name', () => { + it('does NOT match a worker `call X` span without a function_id attribute', () => { + // The harness SDK emits `call ` (service `harness`) for its own + // outbound calls — it must NOT be treated as engine routing, or hiding + // engine routing would sweep up legitimate harness spans. expect( - isEngineRoutingSpan({ name: 'process_event', service_name: 'iii' }), + isEngineRoutingSpan({ + name: 'call turn::get_state', + service_name: 'harness', + attributes: {}, + }), ).toBe(false) }) - it('still matches when service_name is missing (verb prefix is sufficient signal)', () => { + it('does NOT match a non-routing name', () => { expect( - isEngineRoutingSpan({ name: 'call fn-foo', service_name: undefined }), - ).toBe(true) + isEngineRoutingSpan({ + name: 'process_event', + service_name: 'iii', + attributes: { function_id: 'process_event' }, + }), + ).toBe(false) }) }) diff --git a/console/web/src/pages/Traces/lib/spanLabel.ts b/console/web/src/pages/Traces/lib/spanLabel.ts index c92feeeb2..270ca637d 100644 --- a/console/web/src/pages/Traces/lib/spanLabel.ts +++ b/console/web/src/pages/Traces/lib/spanLabel.ts @@ -63,10 +63,21 @@ export function formatSpanLabel( // We keep `service_name` in the `Pick<...>` so existing callers and // fixtures continue to compile — the predicate just no longer uses it. +// +// The name prefix alone is NOT sufficient: the harness SDK emits its own +// client span literally named `call ` (service `harness`) alongside the +// engine's `call ` invocation span. Gating only on the prefix swept up +// those worker spans too, collapsing legitimate harness work. Engine-emitted +// routing spans carry a `function_id` attribute (set by the engine's +// invocation instrumentation, see `engine/src/invocation/mod.rs`); worker +// `call ` spans do not — so we additionally require that marker. export function isEngineRoutingSpan( - span: Pick, + span: Pick & { + attributes?: Record + }, ): boolean { - return ENGINE_VERB_PREFIXES.some((p) => span.name.startsWith(p)) + if (!ENGINE_VERB_PREFIXES.some((p) => span.name.startsWith(p))) return false + return span.attributes?.function_id != null } export function isEngineRoutingPair( diff --git a/console/web/src/pages/Traces/lib/spanTree.test.ts b/console/web/src/pages/Traces/lib/spanTree.test.ts index 76879b852..9565e155f 100644 --- a/console/web/src/pages/Traces/lib/spanTree.test.ts +++ b/console/web/src/pages/Traces/lib/spanTree.test.ts @@ -10,6 +10,16 @@ import type { VisualizationSpan } from './traceTransform' function makeSpan( overrides: Partial = {}, ): VisualizationSpan { + // Engine routing fixtures (`handle_invocation X` / `call X`) carry a + // `function_id` attribute in reality — `isEngineRoutingSpan` now requires it + // to avoid sweeping up worker `call X` spans. Default it from the name so + // engine-routing cases don't have to spell it out (an explicit `attributes` + // override still wins). + const name = overrides.name ?? 'span' + const engineFid = /^(?:handle_invocation|call) (.+)$/.exec(name)?.[1] + const attributes: Record = engineFid + ? { function_id: engineFid } + : {} return { span_id: 's-1', trace_id: 't-1', @@ -19,7 +29,7 @@ function makeSpan( start_percent: 0, width_percent: 100, status: 'ok', - attributes: {}, + attributes, events: [], links: [], service_name: 'svc', From 99899e697178005b6d202e77083fb02a49152699 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Thu, 4 Jun 2026 08:24:49 -0300 Subject: [PATCH 3/3] feat(console): direct session-events live stream + traces search dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscribe each chat session directly to its `agent::events` stream via a scoped engine stream trigger (group_id = session_id), consumed in the console (session-events-live.ts), replacing the harness fanout hop (`ui::subscribe` → per-browser `ui::session::event` push). Drops the harness agent-events pump. The handler is `iii::`-prefixed so its delivery spans are engine-internal — hidden from the traces view and skipped by the trigger loop-break, matching `traces-live.ts`. Also collapse trace search results to one row per trace (`dedupeToTraceRoots`): an operation search uses `search_all_spans`, which returns every span of a matching trace, and the flat list is one-row-per- trace. --- console/web/src/lib/backend/real.ts | 44 ++--- .../lib/backend/session-events-live.test.ts | 155 ++++++++++++++++++ .../src/lib/backend/session-events-live.ts | 99 +++++++++++ console/web/src/lib/backend/types.ts | 5 +- console/web/src/lib/iii-client.ts | 5 +- .../src/pages/Traces/hooks/useTraceData.ts | 13 +- .../pages/Traces/lib/traceListItem.test.ts | 66 ++++++++ .../web/src/pages/Traces/lib/traceListItem.ts | 35 ++++ console/web/src/types/iii-agent-event.ts | 9 - harness/README.md | 2 +- harness/docs/architecture.md | 6 +- harness/docs/workers/harness.md | 23 ++- harness/src/harness/fanout/agent-events.ts | 64 -------- harness/src/harness/fanout/index.ts | 7 - harness/src/harness/main.ts | 2 +- harness/src/index.ts | 2 +- 16 files changed, 405 insertions(+), 132 deletions(-) create mode 100644 console/web/src/lib/backend/session-events-live.test.ts create mode 100644 console/web/src/lib/backend/session-events-live.ts delete mode 100644 harness/src/harness/fanout/agent-events.ts diff --git a/console/web/src/lib/backend/real.ts b/console/web/src/lib/backend/real.ts index 2dc768ec8..ec676f9d6 100644 --- a/console/web/src/lib/backend/real.ts +++ b/console/web/src/lib/backend/real.ts @@ -7,11 +7,8 @@ import { parseCatalogModelKey } from '@/lib/catalog-model-key' import { getIiiClient } from '@/lib/iii-client' import { newMessageId } from '@/lib/session-id' import type { Mode, ModelId } from '@/types/chat' -import type { - AgentEvent, - AgentMessage, - SessionEventEnvelope, -} from '@/types/iii-agent-event' +import type { AgentEvent, AgentMessage } from '@/types/iii-agent-event' +import { startSessionEventsSubscription } from './session-events-live' import { createAgentEventTranslator } from './translate' import type { ChatBackend, @@ -57,24 +54,25 @@ async function* realStream( r?.() } - const off = client.on('ui::session::event', (env) => { - if (!env || env.session_id !== sessionId || !env.event) return - queue.push(env.event) - wake() - }) + // Subscribe directly to this session's `agent::events` stream via a scoped + // engine stream trigger (`group_id = sessionId`), replacing the harness + // fanout hop (`ui::subscribe` → per-browser `ui::session::event` push). + // Registered before the `harness::trigger` kickoff below — both travel the + // same ordered WS connection, so the trigger is in place before the turn's + // first event is written. + const stopSubscription = startSessionEventsSubscription( + client, + sessionId, + (event) => { + queue.push(event) + wake() + }, + ) const onAbort = () => wake() signal?.addEventListener('abort', onAbort, { once: true }) - let subscribed = false - try { - await client.call('ui::subscribe', { - browser_id: client.browserId, - session_id: sessionId, - }) - subscribed = true - const { translate } = createAgentEventTranslator() client @@ -155,15 +153,7 @@ async function* realStream( } } finally { signal?.removeEventListener('abort', onAbort) - off() - if (subscribed) { - await client - .call('ui::unsubscribe', { - browser_id: client.browserId, - session_id: sessionId, - }) - .catch(() => {}) - } + stopSubscription() } } diff --git a/console/web/src/lib/backend/session-events-live.test.ts b/console/web/src/lib/backend/session-events-live.test.ts new file mode 100644 index 000000000..5ecdfc8c1 --- /dev/null +++ b/console/web/src/lib/backend/session-events-live.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest' +import type { IiiClient } from '@/lib/iii-client' +import type { AgentEvent } from '@/types/iii-agent-event' +import { + extractSessionEvent, + startSessionEventsSubscription, +} from './session-events-live' + +/** + * Build the raw `agent::events` stream frame the engine delivers to a stream + * trigger handler: `serde_json::to_value(StreamWrapperMessage)` → + * `{ type, timestamp, streamName, groupId, id, event: { data } }`, where + * `event.data` is the AgentEvent the harness wrote via `stream::set`. + */ +function frame(groupId: string, event: AgentEvent, opts?: { flat?: boolean }) { + if (opts?.flat) { + return { groupId, streamName: 'agent::events', data: event } + } + return { + type: 'set', + timestamp: 1, + streamName: 'agent::events', + groupId, + id: `${groupId}-epoch-00000000`, + event: { data: event }, + } +} + +const UPDATE = { type: 'message_update' } as unknown as AgentEvent +const END = { type: 'agent_end', messages: [] } as unknown as AgentEvent + +describe('extractSessionEvent', () => { + it('extracts the inner AgentEvent from a real engine frame (event.data)', () => { + expect(extractSessionEvent(frame('sess-1', UPDATE), 'sess-1')).toEqual( + UPDATE, + ) + }) + + it('falls back to a flat `data` field when there is no event wrapper', () => { + expect( + extractSessionEvent(frame('sess-1', END, { flat: true }), 'sess-1'), + ).toEqual(END) + }) + + it('accepts the snake_case group_id key as well as camelCase groupId', () => { + const snake = { group_id: 'sess-1', data: UPDATE } + expect(extractSessionEvent(snake, 'sess-1')).toEqual(UPDATE) + }) + + it('drops a frame whose group_id is a different session', () => { + expect(extractSessionEvent(frame('sess-2', UPDATE), 'sess-1')).toBeNull() + }) + + it('drops a frame with no group_id (cannot confirm ownership)', () => { + expect(extractSessionEvent({ data: UPDATE }, 'sess-1')).toBeNull() + }) + + it('returns null for null / non-object / payload-less frames', () => { + expect(extractSessionEvent(null, 'sess-1')).toBeNull() + expect(extractSessionEvent('nope', 'sess-1')).toBeNull() + expect(extractSessionEvent({ groupId: 'sess-1' }, 'sess-1')).toBeNull() + }) +}) + +function fakeClient() { + const triggers: Array<{ + type: string + function_id: string + config?: unknown + }> = [] + let handler: ((p: unknown) => void) | null = null + const offHandler = vi.fn() + const triggerUnregister = vi.fn() + + const on = vi.fn((_fn: string, h: (p: unknown) => void) => { + handler = h + return offHandler + }) + const registerTrigger = vi.fn( + (input: { type: string; function_id: string; config?: unknown }) => { + triggers.push(input) + return triggerUnregister + }, + ) + + const client = { + browserId: 'console-test', + on, + registerTrigger, + call: vi.fn(), + addConnectionStateListener: vi.fn(), + dispose: vi.fn(async () => {}), + } as unknown as IiiClient + + return { + client, + on, + registerTrigger, + triggers, + offHandler, + triggerUnregister, + fire: (f: unknown) => handler?.(f), + } +} + +describe('startSessionEventsSubscription', () => { + it('registers an iii::-prefixed handler and a stream trigger scoped to the session', () => { + const { client, on, triggers } = fakeClient() + + startSessionEventsSubscription(client, 'sess-1', () => {}) + + expect(on).toHaveBeenCalledWith( + 'iii::console::session_event', + expect.any(Function), + ) + expect(triggers).toEqual([ + { + type: 'stream', + function_id: 'iii::console::session_event::console-test', + config: { stream_name: 'agent::events', group_id: 'sess-1' }, + }, + ]) + }) + + it('delivers each extracted AgentEvent for this session to onEvent', () => { + const { client, fire } = fakeClient() + const onEvent = vi.fn() + + startSessionEventsSubscription(client, 'sess-1', onEvent) + fire(frame('sess-1', UPDATE)) + + expect(onEvent).toHaveBeenCalledTimes(1) + expect(onEvent).toHaveBeenCalledWith(UPDATE) + }) + + it('does not deliver a frame addressed to another session', () => { + const { client, fire } = fakeClient() + const onEvent = vi.fn() + + startSessionEventsSubscription(client, 'sess-1', onEvent) + fire(frame('sess-2', UPDATE)) + + expect(onEvent).not.toHaveBeenCalled() + }) + + it('unregisters the handler and the trigger on cleanup', () => { + const { client, offHandler, triggerUnregister } = fakeClient() + + const stop = startSessionEventsSubscription(client, 'sess-1', () => {}) + stop() + + expect(offHandler).toHaveBeenCalledTimes(1) + expect(triggerUnregister).toHaveBeenCalledTimes(1) + }) +}) diff --git a/console/web/src/lib/backend/session-events-live.ts b/console/web/src/lib/backend/session-events-live.ts new file mode 100644 index 000000000..7158d77bd --- /dev/null +++ b/console/web/src/lib/backend/session-events-live.ts @@ -0,0 +1,99 @@ +/** + * Direct subscription to a session's `agent::events` stream, replacing the + * harness fanout hop (`ui::subscribe` → per-browser `ui::session::event` push). + * + * The browser registers a local handler and binds a SCOPED engine stream + * trigger (`config.group_id = session_id`) to it. The engine matches stream + * triggers by `(stream_name, group_id, item_id)` and delivers only matching + * frames straight to this browser's WS connection (engine + * `stream.rs::invoke_triggers`), so a browser receives exactly its own + * session's events — without the harness re-pushing them, and with no + * `harness::fanout::*` / per-browser `ui::session::event` spans. + * + * The handler is named with the `iii::` prefix (`is_iii_builtin_function_id`), + * so the spans produced by DELIVERING this trigger are tagged + * `iii.function.kind=internal` — hidden from the Traces view by the default + * `include_internal:false` query and skipped by the engine's trigger + * loop-break, matching the `traces-live.ts` approach. Without this, every + * delivery would flood the trace list with `session_event` spans. + * + * The iii-browser-sdk replays both registered functions and triggers on + * reconnect (see `onSocketOpen`), so the trigger re-binds automatically; the + * chat backend re-seeds turn state on start via `turn::get_state`, mirroring + * the pre-existing fanout behavior (no per-event replay on reconnect). + */ + +import type { IiiClient } from '@/lib/iii-client' +import type { AgentEvent } from '@/types/iii-agent-event' + +/** iii:: prefix → engine-internal → delivery spans hidden + trigger loop-break skip. */ +const SESSION_EVENT_FN = 'iii::console::session_event' +/** The firehose stream the harness writes every agent event onto. */ +const EVENTS_STREAM = 'agent::events' + +/** + * Pull the AgentEvent out of a raw `agent::events` stream frame, scoped to one + * session. The engine serializes `StreamWrapperMessage` as + * `{ groupId, event: { data }, … }`; some shapes carry a flat `data`. This + * mirrors the extraction the (now-removed) harness `agent::events` fanout + * pump performed before browsers subscribed to the stream directly. + * + * Returns null when the frame is malformed, carries no extractable event, or + * is addressed to a different session. The stream trigger is already + * group-scoped, so the session check is defense-in-depth against mis-delivery + * and preserves the strict `session_id` guard the fanout path enforced. + */ +export function extractSessionEvent( + frame: unknown, + sessionId: string, +): AgentEvent | null { + if (!frame || typeof frame !== 'object') return null + const obj = frame as Record + + const groupId = + (typeof obj.groupId === 'string' && obj.groupId) || + (typeof obj.group_id === 'string' && obj.group_id) || + null + if (groupId !== sessionId) return null + + const wrapper = + obj.event && typeof obj.event === 'object' + ? (obj.event as Record) + : null + const inner = wrapper && 'data' in wrapper ? wrapper.data : (obj.data ?? null) + if (!inner || typeof inner !== 'object') return null + return inner as AgentEvent +} + +/** + * Register the handler + a `agent::events` stream trigger scoped to + * `sessionId`, delivering each extracted AgentEvent to `onEvent`. Returns a + * cleanup that unregisters both (replacing the old `ui::unsubscribe`). + */ +export function startSessionEventsSubscription( + client: Pick, + sessionId: string, + onEvent: (event: AgentEvent) => void, +): () => void { + const off = client.on(SESSION_EVENT_FN, (frame: unknown) => { + const event = extractSessionEvent(frame, sessionId) + if (event) onEvent(event) + }) + + // `on()` registers under `::`; the trigger must target that id. + const functionId = `${SESSION_EVENT_FN}::${client.browserId}` + const offTrigger = client.registerTrigger({ + type: 'stream', + function_id: functionId, + config: { stream_name: EVENTS_STREAM, group_id: sessionId }, + }) + + return () => { + off() + try { + offTrigger() + } catch { + // SDK already disposed; nothing to do. + } + } +} diff --git a/console/web/src/lib/backend/types.ts b/console/web/src/lib/backend/types.ts index 9765ff7a3..62a1cfbf4 100644 --- a/console/web/src/lib/backend/types.ts +++ b/console/web/src/lib/backend/types.ts @@ -70,8 +70,9 @@ export interface ChatStreamOptions { * for the same conversation must pass the same value so the engine * groups every turn under one session in the traces UI. * - * The real backend uses it as `session_id` in `ui::subscribe` and - * `run::start`; the mock backend ignores it. When omitted, the real + * The real backend uses it as the `group_id` of its scoped `agent::events` + * stream trigger and as `session_id` in `harness::trigger`; the mock backend + * ignores it. When omitted, the real * backend falls back to a fresh `console-` so callers that * haven't been updated yet still work (with the pre-fix behavior of * one session per send). diff --git a/console/web/src/lib/iii-client.ts b/console/web/src/lib/iii-client.ts index fd96d33ae..c74d712fc 100644 --- a/console/web/src/lib/iii-client.ts +++ b/console/web/src/lib/iii-client.ts @@ -10,8 +10,9 @@ * which proxies `/ws` to the engine). * 2. Open the WebSocket via `iii-browser-sdk::registerWorker(url)`. * 3. Mint a stable `browser_id` for this page; per-browser handlers are - * registered under `::` so the harness fanout - * can target this specific browser when it pushes events. + * registered under `::` so engine triggers — the + * browser's own scoped stream subscriptions and the harness sessions + * fan-out — deliver to this specific browser's connection. * * Once `_clientPromise` is resolved, every other call (`call`, `on`, * `dispose`) goes over the single WS connection. diff --git a/console/web/src/pages/Traces/hooks/useTraceData.ts b/console/web/src/pages/Traces/hooks/useTraceData.ts index 3fcbf8a9e..536e91f51 100644 --- a/console/web/src/pages/Traces/hooks/useTraceData.ts +++ b/console/web/src/pages/Traces/hooks/useTraceData.ts @@ -1,7 +1,11 @@ import { useQuery } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' import { fetchTraces, type TracesFilterParams } from '../api/traces' -import { fingerprintTraceList, mapSpanToListItem } from '../lib/traceListItem' +import { + dedupeToTraceRoots, + fingerprintTraceList, + mapSpanToListItem, +} from '../lib/traceListItem' const DEFAULT_TRACE_LIMIT = 500 @@ -78,7 +82,12 @@ export function useTraceData({ if (!tracesData) return if (tracesData.spans && tracesData.spans.length > 0) { - const traces: TraceListItem[] = tracesData.spans.map(mapSpanToListItem) + // Search uses `search_all_spans`, which returns every span of each + // matching trace; collapse to one row per trace so the flat list stays + // trace-per-row (no-op for the non-search roots-only response). + const traces: TraceListItem[] = dedupeToTraceRoots(tracesData.spans).map( + mapSpanToListItem, + ) traces.sort((a, b) => b.startTime - a.startTime) diff --git a/console/web/src/pages/Traces/lib/traceListItem.test.ts b/console/web/src/pages/Traces/lib/traceListItem.test.ts index 0f0c8367b..33ea84347 100644 --- a/console/web/src/pages/Traces/lib/traceListItem.test.ts +++ b/console/web/src/pages/Traces/lib/traceListItem.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest' import type { StoredSpan } from '../api/traces' import type { TraceListItem } from '../hooks/useTraceData' import { + dedupeToTraceRoots, fingerprintTraceList, mapSpanToListItem, normalizeSpanAttributes, @@ -30,6 +31,71 @@ function makeSpan(overrides: Partial = {}): StoredSpan { } } +describe('dedupeToTraceRoots', () => { + it('collapses a search_all_spans flood to one root per trace', () => { + // What the engine returns for a `harness::trigger` search: the whole turn. + const spans = [ + makeSpan({ + span_id: 'root', + name: 'handle_invocation harness::trigger', + start_time_unix_nano: 100, + }), + makeSpan({ + span_id: 'call', + parent_span_id: 'root', + name: 'call harness::trigger', + start_time_unix_nano: 110, + }), + makeSpan({ + span_id: 'child', + parent_span_id: 'call', + name: 'run::start', + start_time_unix_nano: 120, + }), + ] + + const out = dedupeToTraceRoots(spans) + expect(out).toHaveLength(1) + expect(out[0].span_id).toBe('root') + }) + + it('keeps one root per distinct trace', () => { + const out = dedupeToTraceRoots([ + makeSpan({ trace_id: 'a', span_id: 'a-root' }), + makeSpan({ trace_id: 'a', span_id: 'a-child', parent_span_id: 'a-root' }), + makeSpan({ trace_id: 'b', span_id: 'b-root' }), + ]) + expect(out.map((s) => s.trace_id).sort()).toEqual(['a', 'b']) + expect(out.find((s) => s.trace_id === 'a')?.span_id).toBe('a-root') + }) + + it('is a no-op for a roots-only list (non-search response)', () => { + const spans = [ + makeSpan({ trace_id: 'a', span_id: 'a' }), + makeSpan({ trace_id: 'b', span_id: 'b' }), + ] + expect(dedupeToTraceRoots(spans)).toHaveLength(2) + }) + + it('falls back to the earliest span when no root is present', () => { + // Root span aged out of the ring buffer; keep the earliest survivor. + const out = dedupeToTraceRoots([ + makeSpan({ + span_id: 'late', + parent_span_id: 'gone', + start_time_unix_nano: 200, + }), + makeSpan({ + span_id: 'early', + parent_span_id: 'gone', + start_time_unix_nano: 150, + }), + ]) + expect(out).toHaveLength(1) + expect(out[0].span_id).toBe('early') + }) +}) + describe('normalizeSpanAttributes', () => { it('normalizes the array-of-tuples shape into a flat object', () => { const out = normalizeSpanAttributes([ diff --git a/console/web/src/pages/Traces/lib/traceListItem.ts b/console/web/src/pages/Traces/lib/traceListItem.ts index 742fbdcc0..93c43146d 100644 --- a/console/web/src/pages/Traces/lib/traceListItem.ts +++ b/console/web/src/pages/Traces/lib/traceListItem.ts @@ -80,6 +80,41 @@ export function mapSpanToListItem(span: StoredSpan): TraceListItem { } } +/** + * Collapse a span list to one representative span per `trace_id` — the root + * (no parent) when present, else the earliest-started span. + * + * The flat-list TRACES view is one row per trace. A plain `engine::traces::list` + * already returns root spans only, but the SEARCH path passes + * `search_all_spans: true`, which returns EVERY span of each matching trace + * (so a query like `harness::trigger` matches a child span and the engine + * hands back the whole turn). Collapsing here keeps the list one-row-per-trace + * regardless, and is a no-op for the non-search response (already roots). + */ +export function dedupeToTraceRoots( + spans: ReadonlyArray, +): StoredSpan[] { + const byTrace = new Map() + for (const span of spans) { + const existing = byTrace.get(span.trace_id) + if (!existing) { + byTrace.set(span.trace_id, span) + continue + } + const spanIsRoot = !span.parent_span_id + const existingIsRoot = !existing.parent_span_id + if (spanIsRoot && !existingIsRoot) { + byTrace.set(span.trace_id, span) + } else if ( + spanIsRoot === existingIsRoot && + span.start_time_unix_nano < existing.start_time_unix_nano + ) { + byTrace.set(span.trace_id, span) + } + } + return [...byTrace.values()] +} + /** * Stable identity fingerprint for a list of TraceListItems. Used by * the hook to dedupe back-to-back fetches that return the same rows. diff --git a/console/web/src/types/iii-agent-event.ts b/console/web/src/types/iii-agent-event.ts index bba2839cf..d83297e75 100644 --- a/console/web/src/types/iii-agent-event.ts +++ b/console/web/src/types/iii-agent-event.ts @@ -218,12 +218,3 @@ export type TurnStateChangedEvent = Extract< AgentEvent, { type: 'turn_state_changed' } > - -/** - * Envelope the harness fanout pushes to `ui::session::event::`. - * See `harness/src/fanout.rs` `subscribers_for(session_id)`. - */ -export interface SessionEventEnvelope { - session_id: string - event: AgentEvent -} diff --git a/harness/README.md b/harness/README.md index 2337cf021..2cb1eb628 100644 --- a/harness/README.md +++ b/harness/README.md @@ -12,7 +12,7 @@ alongside `harness` over the iii bus. | Folder | Bus surface | Role | |---|---|---| -| `src/harness/` | `ui::subscribe`/`unsubscribe`, `harness::fs::read_inline`, `policy::check_permissions`, `harness::provider::{register,resolve,list}` | Meta-worker; loads `iii-permissions.yaml`; spins up `ui::*` fanout pumps; owns the provider registry + the `harness` entry in the `configuration` worker (api keys, per-provider settings, permissions). | +| `src/harness/` | `ui::subscribe`/`unsubscribe`, `harness::fs::read_inline`, `policy::check_permissions`, `harness::provider::{register,resolve,list}` | Meta-worker; loads `iii-permissions.yaml`; spins up the sessions fan-out pump; owns the provider registry + the `harness` entry in the `configuration` worker (api keys, per-provider settings, permissions). | | `src/approval-gate/` | `approval::resolve` | Persists operator decisions to scope `approvals` (turn-orchestrator reacts via `turn::on_approval`); default mode seeded from `harness` config `permissions.default_mode`. | | `src/turn-orchestrator/` | `run::start`, `turn::{state}`, `turn::get_state` | Durable FSM driving each agent turn; `dispatchWithHook` approval chokepoint. | | `src/session/` | `session-tree::*` (11 fns), `session-inbox::*` (3 fns) | Branching session storage + per-session inbox queues. | diff --git a/harness/docs/architecture.md b/harness/docs/architecture.md index 863c21e64..df3c00eb5 100644 --- a/harness/docs/architecture.md +++ b/harness/docs/architecture.md @@ -18,7 +18,7 @@ workers. | Worker | Folder | Role | Doc | |---|---|---|---| -| harness | [src/harness/](harness/src/harness/) | Meta-worker; loads `iii-permissions.yaml`, exposes `harness::trigger` (WS ingestion bridge — see [Telemetry & trace correlation](#telemetry--trace-correlation)) / `policy::check_permissions` / `ui::*` / `harness::provider::{register,resolve,list}`, spins up `agent::events` fan-out. Owns the provider registry + the `harness` entry in the `configuration` worker (credentials, settings, permissions — see [storage.md](harness/docs/storage.md)). | [workers/harness.md](harness/docs/workers/harness.md) | +| harness | [src/harness/](harness/src/harness/) | Meta-worker; loads `iii-permissions.yaml`, exposes `harness::trigger` (WS ingestion bridge — see [Telemetry & trace correlation](#telemetry--trace-correlation)) / `policy::check_permissions` / `ui::*` / `harness::provider::{register,resolve,list}`. Owns the provider registry + the `harness` entry in the `configuration` worker (credentials, settings, permissions — see [storage.md](harness/docs/storage.md)). | [workers/harness.md](harness/docs/workers/harness.md) | | turn-orchestrator | [src/turn-orchestrator/](harness/src/turn-orchestrator/) | Durable FSM driving each agent turn; `dispatchWithHook` approval chokepoint. | [workers/turn-orchestrator.md](harness/docs/workers/turn-orchestrator.md) | | approval-gate | [src/approval-gate/](harness/src/approval-gate/) | Registers `approval::resolve`; persists decisions to scope `approvals`. Wake via `turn::on_approval` state trigger. Default mode from `harness` config `permissions.default_mode`. | [workers/approval-gate.md](harness/docs/workers/approval-gate.md) | | session | [src/session/](harness/src/session/) | Branching session storage (`session-tree::*`) plus per-session inbox queues (`session-inbox::*`). | [workers/session.md](harness/docs/workers/session.md) | @@ -92,10 +92,10 @@ flowchart LR provLlama -- "harness::provider::resolve (optional)" --> harness harness -- "configuration::get/set/register (harness entry)" --> config - state -- "agent::events stream" --> harness + state -- "agent::events stream (scoped trigger)" --> client state -- "agent::events stream" --> compact state -- "state trigger (scope=turn_state)" --> harness - harness -- "ui::session::event::" --> client + harness -- "ui::sessions::changed::" --> client compact -- "session-tree::compact" --> session ``` diff --git a/harness/docs/workers/harness.md b/harness/docs/workers/harness.md index 790c3b1d5..d33da29dd 100644 --- a/harness/docs/workers/harness.md +++ b/harness/docs/workers/harness.md @@ -6,15 +6,15 @@ surface. ## Purpose The harness worker is the glue layer of the bundle. It exposes the policy -surface every other worker relies on, terminates the -operator-facing `ui::*` plane, and pumps `agent::events` out to subscribed -browsers. On boot it reads [config.yaml](harness/config.yaml) for the +surface every other worker relies on and terminates the +operator-facing `ui::*` plane. On boot it reads [config.yaml](harness/config.yaml) for the engine URL and the permissions file path, loads [iii-permissions.yaml](iii-permissions.yaml), and starts watching it with `chokidar` so policy changes apply without a restart. It does NOT participate in the durable run loop and registers no triggers -that drive transitions; its fan-out trigger is a passive stream subscriber. +that drive transitions; its only fan-out trigger is the passive sessions +state trigger. ## Registered functions @@ -23,19 +23,17 @@ that drive transitions; its fan-out trigger is a passive stream subscriber. - `ui::unsubscribe` — Remove a browser's subscription to a session (or its all-sessions sub if session_id is null). - `harness::fs::read_inline` — Read a host file via shell::fs::read, drain its channel, and return a `{content:[{text}], details:{size, truncated, bytes_read}}` envelope (max 256 KiB inline by default). - `policy::check_permissions` — Evaluate a function call against the current `iii-permissions.yaml`. Returns `{ decision: "allow" | "deny" | "needs_approval", rule_id?, matched_constraint? }`. -- `harness::fanout::agent_event_handler` — Internal: `agent::events` fanout handler. - `harness::fanout::session_created` — Internal handler invoked by the sessions state trigger; fans the new session id out to every all-sessions subscriber via `ui::sessions::changed::`. Gates in-handler on the `state:created` marker. ## Triggers -- **Stream subscriber** on `agent::events` → `harness::fanout::agent_event_handler`. Registered by [src/harness/fanout/agent-events.ts](harness/src/harness/fanout/agent-events.ts). - **State trigger** on `scope: turn_state` (no `condition_function_id`) → `harness::fanout::session_created`. Lives in [src/harness/fanout/sessions-poll.ts](harness/src/harness/fanout/sessions-poll.ts). The handler gates on `state:created` events where key = session id — the first persist of a turn record signals session creation. (This replaced the earlier `session_index` marker scope.) -The fanout handler forwards every `agent::events` frame to the per-browser -endpoint `ui::session::event::` for each browser whose -`ui::subscribe` set matches the event's `session_id` (or who is subscribed -to all sessions). Browsers that respond with `function_not_found` are -evicted from the in-process subscription set. +The harness no longer fans `agent::events` out to browsers: each browser +subscribes directly to the engine `agent::events` stream with a +`group_id`-scoped stream trigger (see `console/web` `session-events-live.ts`). +The turn-orchestrator writes the stream (`turn-orchestrator/events.ts`); the +harness meta-worker no longer re-pushes it. ## State keys @@ -84,7 +82,6 @@ From [src/harness/iii.worker.yaml](harness/src/harness/iii.worker.yaml): | [src/harness/policy/permissions.ts](harness/src/harness/policy/permissions.ts) | `Permissions` — parses the YAML into compiled rules and evaluates a call via `check(function_id, args)` (first match wins → `Decision`). | | [src/harness/policy/compile.ts](harness/src/harness/policy/compile.ts) | `compileRule` / `matchFunctionId` / `matchConstraints` — compiles a `RuleSpec` into a `CompiledRule`, matches a `function_id` by exact equality or `*` glob, and evaluates `equals` / `matches` (regex) arg constraints. | | [src/harness/policy/types.ts](harness/src/harness/policy/types.ts) | `RuleSpec`, `ConstraintSpec`, `Decision`, `MatchedConstraint` types for `iii-permissions.yaml` rules and evaluation results. | -| [src/harness/fanout/index.ts](harness/src/harness/fanout/index.ts) | Spawns the two fan-out pumps. | -| [src/harness/fanout/agent-events.ts](harness/src/harness/fanout/agent-events.ts) | `agent::events` stream subscriber → per-browser fan-out. | +| [src/harness/fanout/index.ts](harness/src/harness/fanout/index.ts) | Spawns the sessions fan-out pump. | | [src/harness/fanout/sessions-poll.ts](harness/src/harness/fanout/sessions-poll.ts) | State-trigger handler on scope `turn_state` that fans new session ids to every all-sessions subscriber via `ui::sessions::changed::`. | | [src/harness/iii.worker.yaml](harness/src/harness/iii.worker.yaml) | iii worker manifest (dependencies, install/start scripts). | diff --git a/harness/src/harness/fanout/agent-events.ts b/harness/src/harness/fanout/agent-events.ts deleted file mode 100644 index 5680ce49a..000000000 --- a/harness/src/harness/fanout/agent-events.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Subscribe to `agent::events` and fan-out to per-browser - * `ui::session::event::` triggers. Mirrors - * `harness/src/fanout.rs::register_agent_event_pump`. - */ - -import type { ISdk, Trigger } from '../../runtime/iii.js'; -import { logger } from '../../runtime/otel.js'; -import type { FanoutState } from '../ui-subscribe.js'; - -const FN_ID = 'harness::fanout::agent_event_handler'; - -export function spawnAgentEventsPump(iii: ISdk, state: FanoutState): Trigger | null { - iii.registerFunction( - FN_ID, - async (frame: unknown) => { - const obj = frame && typeof frame === 'object' ? (frame as Record) : null; - if (!obj) return null; - const session_id = - (typeof obj.groupId === 'string' && obj.groupId) || - (typeof obj.group_id === 'string' && obj.group_id) || - null; - if (!session_id) return null; - const inner = - obj.event && - typeof obj.event === 'object' && - 'data' in (obj.event as Record) - ? (obj.event as Record).data - : (obj.data ?? null); - const payload = { session_id, event: inner }; - const browsers = state.subscribersFor(session_id); - for (const browser_id of browsers) { - iii - .trigger({ - function_id: `ui::session::event::${browser_id}`, - payload, - timeoutMs: 2_000, - }) - .catch((err) => { - logger.debug('ui::session::event push failed', { - browser_id, - err: String(err), - }); - const msg = String(err); - if (/function_not_found/.test(msg)) state.evictBrowser(browser_id); - }); - } - return null; - }, - { description: 'Internal: agent::events fanout handler.' }, - ); - try { - return iii.registerTrigger({ - type: 'stream', - function_id: FN_ID, - config: { stream_name: 'agent::events' }, - }); - } catch (err) { - logger.warn('agent::events stream subscriber registration failed', { - err: String(err), - }); - return null; - } -} diff --git a/harness/src/harness/fanout/index.ts b/harness/src/harness/fanout/index.ts index 70788a56b..6c309dfec 100644 --- a/harness/src/harness/fanout/index.ts +++ b/harness/src/harness/fanout/index.ts @@ -1,6 +1,5 @@ import type { ISdk } from '../../runtime/iii.js'; import type { FanoutState } from '../ui-subscribe.js'; -import { spawnAgentEventsPump } from './agent-events.js'; import { spawnSessionsPoll } from './sessions-poll.js'; export type FanoutPumps = { @@ -8,15 +7,9 @@ export type FanoutPumps = { }; export function spawnPumps(iii: ISdk, state: FanoutState): FanoutPumps { - const agentEventsTrigger = spawnAgentEventsPump(iii, state); const stopSessions = spawnSessionsPoll(iii, state); return { async shutdown() { - try { - agentEventsTrigger?.unregister(); - } catch { - // ignore - } stopSessions(); }, }; diff --git a/harness/src/harness/main.ts b/harness/src/harness/main.ts index 54aa88dcc..be9978abf 100644 --- a/harness/src/harness/main.ts +++ b/harness/src/harness/main.ts @@ -5,6 +5,6 @@ import { register } from './register.js'; await bootstrapWorker({ name: 'harness', description: - 'Meta-worker: ui::subscribe/unsubscribe, harness::fs::read_inline, policy::check_permissions, agent::events fanout to subscribed browsers.', + 'Meta-worker: ui::subscribe/unsubscribe, harness::fs::read_inline, policy::check_permissions.', register: (iii, ctx) => register(iii, ctx), }); diff --git a/harness/src/index.ts b/harness/src/index.ts index 8d6c53e03..9e510e287 100644 --- a/harness/src/index.ts +++ b/harness/src/index.ts @@ -38,7 +38,7 @@ const WORKERS: readonly WorkerDefinition[] = [ { name: 'harness', description: - 'Meta-worker: ui::subscribe/unsubscribe, harness::fs::read_inline, policy::check_permissions, agent::events fanout to subscribed browsers.', + 'Meta-worker: ui::subscribe/unsubscribe, harness::fs::read_inline, policy::check_permissions.', register: (iii, ctx) => registerHarness(iii, ctx), }, {