/** * DI view — the engine's "Service × Effect × DI" debug surface (the App-scope * `debug` domain, exposed like every other Service over `/api/v1/debug`): * * - Units: the unit tree = ledger tree (`IDebugLedgerService.tree`) — one * collapsible card per scope container, its units with the five-state * cascade badge and the unprovide / update / dispose triggers * (`IDebugCascadeService`), its ledger entries as a nested subtree; * - Deps: the persistent dependency DAG (`IDebugGraphService.graph`) as * root-based Miller columns (`di/DiGraphPanel.tsx`) — column 0 lists the * roots (services nothing consumes), each click opens the next column * with that service's direct dependencies; rows carry path-scoped * relation bars (one per path ancestor with a direct edge) and a * path-root background highlight; * - Events: event subscriptions (`IDebugEventsService.subscriptions`) — * unit-book ledger entries labeled `on:` / * `disposable:EventSubscription` per scope, plus per-bus listener counts * as the fallback side (`di/DiEventsPanel.tsx`); * - Cascade: the cross-scope cascade history rings * (`IDebugCascadeService.history`), newest first; * - Pending: the waiting area + sticky failures per scope * (`IDebugCascadeService.pending`), with an `update` retry per failure. * * All five panels poll on a short interval and refresh eagerly when the * global `event.di.unit_changed` WS frame fires (`useDiQueryInvalidation` * invalidates the `['di']` query prefix). */ import type { UnitState } from '@moonshot-ai/agent-core-v2/_base/di/cascadeEngine'; import type { LedgerEntryInfo } from '@moonshot-ai/agent-core-v2/_base/lifecycle/ledger'; import { IDebugCascadeService, type DebugCascadeEntry, type DebugPendingGroup, } from '@moonshot-ai/agent-core-v2/debug/debugCascade'; import { IDebugGraphService, type DebugGraph } from '@moonshot-ai/agent-core-v2/debug/debugGraph'; import { IDebugEventsService, type DebugEventSubscriptions, } from '@moonshot-ai/agent-core-v2/features/debugEvents/debugEvents'; import { IDebugLedgerService, type DebugLedgerNode, type DebugUnit, } from '@moonshot-ai/agent-core-v2/debug/debugLedger'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useState, type ReactNode } from 'react'; import { useDiQueryInvalidation } from '../activity/di'; import type { InspectClient } from '../channel'; import { useConnection } from '../connection'; import { ActionButton, Badge, ErrorLine } from '../ui'; import { DiEventsPanel } from './di/DiEventsPanel'; import { DiGraphPanel } from './di/DiGraphPanel'; type DiPanel = 'units' | 'graph' | 'events' | 'cascade' | 'pending'; const PANELS: readonly { id: DiPanel; title: string }[] = [ { id: 'units', title: 'Units' }, { id: 'graph', title: 'Deps' }, { id: 'events', title: 'Events' }, { id: 'cascade', title: 'Cascade' }, { id: 'pending', title: 'Pending' }, ]; const REFETCH_INTERVAL_MS = 3000; export function DiInspectionView() { useDiQueryInvalidation(); const [panel, setPanel] = useState('units'); return (
{panel === 'units' ? ( ) : panel === 'graph' ? ( ) : panel === 'events' ? ( ) : panel === 'cascade' ? ( ) : ( )}
); } // --------------------------------------------------------------------------- // Shared data plumbing // --------------------------------------------------------------------------- function useDiQuery(panel: string, fetcher: (klient: InspectClient) => Promise) { const { klient } = useConnection(); return useQuery({ queryKey: ['di', klient.baseUrl, panel], queryFn: () => fetcher(klient), refetchInterval: REFETCH_INTERVAL_MS, }); } /** The loading / error gate shared by every panel. Returns null when data is ready. */ function panelGate(query: { isError: boolean; error: unknown; data: unknown }): ReactNode | null { if (query.isError) return ; if (query.data === undefined) { return
loading…
; } return null; } type DiTriggerAction = 'unprovide' | 'update' | 'dispose'; interface DiTrigger { /** `${action}:${scopePath}:${token}` of the in-flight trigger, if any. */ readonly busy: string | null; readonly error: unknown; readonly run: (action: DiTriggerAction, scopePath: string, token: string) => Promise; } function triggerKey(action: DiTriggerAction, scopePath: string, token: string): string { return `${action}:${scopePath}:${token}`; } /** * The destructive unit triggers (`IDebugCascadeService.unprovide` / `update` * / `dispose`), confirm-gated like the ServiceCard danger actions; a settled * trigger invalidates the `['di']` prefix so every panel converges. */ function useDiTrigger(): DiTrigger { const { klient } = useConnection(); const queryClient = useQueryClient(); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const run = async (action: DiTriggerAction, scopePath: string, token: string): Promise => { if (!window.confirm(`${action} '${token}' @ ${scopePath}?`)) return; setBusy(triggerKey(action, scopePath, token)); setError(null); try { const svc = klient.core(IDebugCascadeService); if (action === 'unprovide') await svc.unprovide(scopePath, token); else if (action === 'update') await svc.update(scopePath, token); else await svc.dispose(scopePath, token); await queryClient.invalidateQueries({ queryKey: ['di'] }); } catch (error) { setError(error); } finally { setBusy(null); } }; return { busy, error, run }; } function truncate(text: string, max: number): string { return text.length > max ? `${text.slice(0, max - 1)}…` : text; } // --------------------------------------------------------------------------- // Units panel — the unit tree = ledger tree // --------------------------------------------------------------------------- const STATE_TONES: Record = { Pending: 'neutral', Activating: 'sky', Active: 'green', Unloading: 'amber', Failed: 'red', }; function UnitStateBadge({ state }: { state?: UnitState }) { if (state === undefined) return unseen; return {state}; } function UnitsPanel() { const query = useDiQuery('units', (klient) => klient.core(IDebugLedgerService).tree()); const trigger = useDiTrigger(); const gate = panelGate(query); if (gate !== null) return gate; const tree = query.data as DebugLedgerNode; return (
{trigger.error !== null ? (
) : null}
); } function ScopeNodeCard({ node, depth, trigger, }: { node: DebugLedgerNode; depth: number; trigger: DiTrigger; }) { const [open, setOpen] = useState(depth < 2); const counts = new Map(); for (const unit of node.units) { if (unit.state === undefined) continue; counts.set(unit.state, (counts.get(unit.state) ?? 0) + 1); } return (
{ setOpen((v) => !v); }} > {open ? '▾' : '▸'} {node.label} {node.path} {node.units.length} units {[...counts.entries()].map(([state, count]) => ( {count} {state} ))}
{open ? (
{node.units.length === 0 ? (
no units
) : ( node.units.map((unit) => ( )) )}
) : null}
{open ? node.children.map((child) => ( )) : null}
); } function UnitRow({ scopePath, unit, trigger, }: { scopePath: string; unit: DebugUnit; trigger: DiTrigger; }) { const [errorOpen, setErrorOpen] = useState(false); const actions: readonly DiTriggerAction[] = ['unprovide', 'update', 'dispose']; return (
{unit.token} {unit.inFlight === true ? in-flight : null} #{unit.uid} {unit.error !== undefined ? ( ) : null} {actions.map((action) => ( void trigger.run(action, scopePath, unit.token)} > {trigger.busy === triggerKey(action, scopePath, unit.token) ? '…' : action} ))}
{errorOpen && unit.error !== undefined ? (
          {unit.error}
        
) : null}
); } const LEDGER_KIND_TONES: Record = { disposer: 'neutral', effect: 'sky', ledger: 'violet', }; function LedgerSection({ entries }: { entries: readonly LedgerEntryInfo[] }) { const [open, setOpen] = useState(false); if (entries.length === 0) return null; return (
{open ? (
{entries.map((entry, i) => ( ))}
) : null}
); } function LedgerEntryRow({ entry }: { entry: LedgerEntryInfo }) { return (
{entry.label} {entry.kind} {entry.children !== undefined && entry.children.length > 0 ? (
{entry.children.map((child, i) => ( ))}
) : null}
); } // --------------------------------------------------------------------------- // Graph panel — the dependency DAG; rendering lives in di/DiGraphPanel.tsx // --------------------------------------------------------------------------- function GraphPanel() { const query = useDiQuery('graph', (klient) => klient.core(IDebugGraphService).graph()); const gate = panelGate(query); if (gate !== null) return gate; return ; } // --------------------------------------------------------------------------- // Events panel — event subscriptions; rendering lives in di/DiEventsPanel.tsx // --------------------------------------------------------------------------- function EventsPanel() { const query = useDiQuery('events', (klient) => klient.core(IDebugEventsService).subscriptions(), ); const gate = panelGate(query); if (gate !== null) return gate; return ; } // --------------------------------------------------------------------------- // Cascade panel — the cross-scope cascade history rings, newest first // --------------------------------------------------------------------------- function CascadePanel() { const query = useDiQuery('cascade', (klient) => klient.core(IDebugCascadeService).history()); const gate = panelGate(query); if (gate !== null) return gate; // seq is a per-engine ring sequence; cross-scope ordering is approximate. const entries = (query.data as DebugCascadeEntry[]).toReversed(); if (entries.length === 0) { return
no cascade transactions yet
; } return (
{entries.map((entry, i) => ( ))}
); } function CascadeEntryCard({ entry }: { entry: DebugCascadeEntry }) { const [open, setOpen] = useState(false); const expandable = entry.failed.length > 0 || entry.affected.length > 0 || entry.tornDown.length > 0; return (
{ setOpen((v) => !v); } : undefined } > #{entry.seq} {truncate(entry.scopePath, 40)} {entry.abortWaited ? abort waited : null} {entry.abortTimedOut ? abort timed out : null} {entry.failed.length > 0 ? {entry.failed.length} failed : null} {entry.reason} {entry.durationMs}ms {expandable ? ( {open ? '▾' : '▸'} ) : null}
{entry.changes.map((change, i) => ( {change.action} {truncate(change.token, 48)} ))}
affected {entry.affected.length} · torn down {entry.tornDown.length} · rebuilt{' '} {entry.rebuilt.length} · failed {entry.failed.length}
{open ? (
{entry.affected.length > 0 ? ( ) : null} {entry.tornDown.length > 0 ? ( ) : null} {entry.failed.length > 0 ? ( ) : null}
) : null}
); } function TokenList({ label, tokens, danger, }: { label: string; tokens: readonly string[]; danger?: boolean; }) { return (
{label}
{tokens.map((token, i) => ( {truncate(token, 64)} ))}
); } // --------------------------------------------------------------------------- // Pending panel — the waiting area + sticky failures per scope // --------------------------------------------------------------------------- function PendingPanel() { const query = useDiQuery('pending', (klient) => klient.core(IDebugCascadeService).pending()); const trigger = useDiTrigger(); const gate = panelGate(query); if (gate !== null) return gate; const groups = query.data as DebugPendingGroup[]; return (
{trigger.error !== null ? (
) : null} {groups.length === 0 ? (
no waiting or failed units
) : ( groups.map((group) => (
{group.scopePath}
{group.waiting.length > 0 ? (
waiting ({group.waiting.length})
{group.waiting.map((unit) => (
{unit.token} missing: {unit.missing.map((dep, i) => ( {truncate(dep, 48)} ))}
))}
) : null} {group.failed.length > 0 ? (
failed ({group.failed.length})
{group.failed.map((unit) => (
{unit.token} void trigger.run('update', group.scopePath, unit.token)} > {trigger.busy === triggerKey('update', group.scopePath, unit.token) ? '…' : 'retry update'}
{unit.error !== undefined ? (
                          {unit.error}
                        
) : null}
))}
) : null}
)) )}
); }