diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index bc51e3540..cc3e4ee7b 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -289,6 +289,7 @@ export function App() { navigate({ tab: "flows", flow: id !== null ? String(id) : null }) } onOpenSymbol={openSymbol} + onOpenWiki={setWikiSlug} /> ) : activeTab === "changes" && selectedProject ? ( diff --git a/graph-ui/src/components/FlowsTab.observed.test.tsx b/graph-ui/src/components/FlowsTab.observed.test.tsx new file mode 100644 index 000000000..3b9041fad --- /dev/null +++ b/graph-ui/src/components/FlowsTab.observed.test.tsx @@ -0,0 +1,30 @@ +/* @vitest-environment jsdom */ +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ObservedChip } from "./FlowsTab"; + +/* The chip is exercised in isolation — FlowsTab itself fetches on mount. + * The all-observed line logic lives in lib/observed.ts and is tested there. */ +describe("ObservedChip", () => { + const observed = { + count: 3, + label: "pytest 2026-08-27", + last_seen: "2026-08-27T14:12:00Z", + }; + + it("renders glyph, word and count, with the run in the title", () => { + render(); + const chip = screen.getByTitle("ran in pytest 2026-08-27 · last 2026-08-27"); + expect(chip).toHaveTextContent("▶ observed ×3"); + /* No onOpenWiki → plain text, no nested button (flow-step rows are buttons). */ + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("opens the wiki entry from the marker word when onOpenWiki is given", () => { + const onOpenWiki = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "observed" })); + expect(onOpenWiki).toHaveBeenCalledWith("observed"); + }); +}); diff --git a/graph-ui/src/components/FlowsTab.tsx b/graph-ui/src/components/FlowsTab.tsx index a78eff392..5bfe6250a 100644 --- a/graph-ui/src/components/FlowsTab.tsx +++ b/graph-ui/src/components/FlowsTab.tsx @@ -10,10 +10,13 @@ import { searchGraph, type FlowDetail, type FlowsPayload, + type ObservedCall, type TracePayload, } from "../lib/atlas"; import { groupFlowsByEntry } from "../lib/flowgroup"; +import { allHopsObserved, newestObserved, observedTitle } from "../lib/observed"; import { formatGuard } from "../lib/why"; +import { MetricChip } from "./MetricChip"; import { AddToPromptButton } from "./PromptBasket"; interface FlowsTabProps { @@ -21,6 +24,7 @@ interface FlowsTabProps { flowId: number | null; onOpenFlow: (id: number | null) => void; onOpenSymbol: (ref: { id?: number; qn?: string }) => void; + onOpenWiki: (slug: string) => void; } function flowToMermaid(detail: FlowDetail): string { @@ -35,6 +39,35 @@ function flowToMermaid(detail: FlowDetail): string { return lines.join("\n"); } +/* "▶ observed ×N" — runtime-evidence marker beside the guard chips: this + * exact hop fired in a recorded run. Glyph + word, never color-only. With + * onOpenWiki the word opens the wiki entry; omit it where the chip sits + * inside a row button (flow steps) — nested buttons are invalid HTML. */ +export function ObservedChip({ + observed, + onOpenWiki, +}: { + observed: ObservedCall; + onOpenWiki?: (slug: string) => void; +}) { + return ( + + ▶{" "} + {onOpenWiki ? ( + + observed + + ) : ( + "observed" + )}{" "} + ×{observed.count} + + ); +} + /* One endpoint picker: type ≥2 chars, pick from symbol matches. */ function EndpointPicker({ project, @@ -118,7 +151,13 @@ function EndpointPicker({ ); } -export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTabProps) { +export function FlowsTab({ + project, + flowId, + onOpenFlow, + onOpenSymbol, + onOpenWiki, +}: FlowsTabProps) { const [payload, setPayload] = useState(null); const [detail, setDetail] = useState(null); const [error, setError] = useState(null); @@ -186,6 +225,13 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab }; }, [payload]); + /* Newest observation each — names the runtime-freshness footers. */ + const traceNewest = useMemo(() => newestObserved(trace?.path ?? []), [trace]); + const detailNewest = useMemo( + () => (detail ? newestObserved(detail.steps) : null), + [detail], + ); + useEffect(() => { if (!copied) return; const timer = setTimeout(() => setCopied(false), 1500); @@ -254,6 +300,11 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab reachable in {trace.hops} hop{trace.hops === 1 ? "" : "s"} via{" "} {trace.mode === "data" ? "data flow" : "calls"}

+ {allHopsObserved(trace.path ?? []) && ( +

+ This whole path was observed in recorded runs. +

+ )}
{(trace.path ?? []).map((step, index) => ( @@ -280,9 +331,19 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab {formatGuard(guard)} ))} + {step.observed && ( + + )} ))}
+ {traceNewest && ( +

+ Runtime markers from recorded runs ({traceNewest.label}). + Unmarked hops are possible, not dead — runs only cover + what they exercised. +

+ )} ) : (

@@ -452,12 +513,20 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab {formatGuard(guard)} ))} + {step.observed && } {step.file_path} ))} + {detailNewest && ( +

+ Runtime markers from recorded runs ({detailNewest.label}). + Unmarked hops are possible, not dead — runs only cover what + they exercised. +

+ )} )} diff --git a/graph-ui/src/lib/atlas.ts b/graph-ui/src/lib/atlas.ts index 7655846d6..a7defffe6 100644 --- a/graph-ui/src/lib/atlas.ts +++ b/graph-ui/src/lib/atlas.ts @@ -138,6 +138,15 @@ export interface FlowsPayload { flows: FlowSummary[]; } +/* Runtime evidence: this exact caller→callee pair fired in a recorded run. + * Absent means "never recorded" — NOT dead; a run only covers what it + * exercised. label/last_seen name the newest run, count spans all runs. */ +export interface ObservedCall { + count: number; + label: string; + last_seen: string; +} + export interface FlowStep { id: number; name: string; @@ -147,6 +156,7 @@ export interface FlowStep { guards?: { kind: string; cond?: string; negated?: boolean }[]; /* Resolver confidence of the edge from the parent step (0..1). */ confidence?: number; + observed?: ObservedCall; } export interface FlowDetail { @@ -283,6 +293,7 @@ export interface TracePayload { name: string; file_path?: string; guards?: { kind: string; cond?: string; negated?: boolean }[]; + observed?: ObservedCall; }[]; } diff --git a/graph-ui/src/lib/observed.test.ts b/graph-ui/src/lib/observed.test.ts new file mode 100644 index 000000000..b72e20d11 --- /dev/null +++ b/graph-ui/src/lib/observed.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { allHopsObserved, newestObserved, observedTitle } from "./observed"; + +const obs = (label: string, last_seen: string, count = 1) => ({ + count, + label, + last_seen, +}); + +describe("allHopsObserved", () => { + it("is false with no hops to judge — empty path or origin alone", () => { + expect(allHopsObserved([])).toBe(false); + expect(allHopsObserved([{}])).toBe(false); + expect(allHopsObserved([{ observed: obs("pytest", "2026-08-27") }])).toBe(false); + }); + + it("is true when every non-root hop is observed — the origin never counts", () => { + expect( + allHopsObserved([ + {}, + { observed: obs("pytest 2026-08-27", "2026-08-27T10:00:00Z") }, + { observed: obs("pytest 2026-08-27", "2026-08-27T10:00:00Z") }, + ]), + ).toBe(true); + }); + + it("is false as soon as one non-root hop was never recorded", () => { + expect( + allHopsObserved([ + {}, + { observed: obs("pytest 2026-08-27", "2026-08-27T10:00:00Z") }, + {}, + ]), + ).toBe(false); + }); +}); + +describe("newestObserved", () => { + it("is null when nothing was observed", () => { + expect(newestObserved([])).toBeNull(); + expect(newestObserved([{}, {}])).toBeNull(); + }); + + it("names the most recently seen run across steps", () => { + const newest = newestObserved([ + { observed: obs("pytest 2026-08-25", "2026-08-25T09:00:00Z") }, + {}, + { observed: obs("pytest 2026-08-27", "2026-08-27T14:12:00Z") }, + { observed: obs("pytest 2026-08-26", "2026-08-26T09:00:00Z") }, + ]); + expect(newest?.label).toBe("pytest 2026-08-27"); + }); +}); + +describe("observedTitle", () => { + it("carries the run label and the ISO date of the newest sighting", () => { + expect(observedTitle(obs("pytest 2026-08-27", "2026-08-27T14:12:00Z", 4))).toBe( + "ran in pytest 2026-08-27 · last 2026-08-27", + ); + }); +}); diff --git a/graph-ui/src/lib/observed.ts b/graph-ui/src/lib/observed.ts new file mode 100644 index 000000000..374a079a0 --- /dev/null +++ b/graph-ui/src/lib/observed.ts @@ -0,0 +1,31 @@ +/* Observed executions — runtime evidence on trace hops and flow steps from + * ingested traces. Presence means the exact caller→callee pair fired in a + * recorded run; absence means "never recorded", which must never be read + * as dead or unused — a run only covers what it exercised. */ +import type { ObservedCall } from "./atlas"; + +/* True when every hop beyond the first is observed (the first entry is the + * origin, not a call). False when there is no hop to judge. */ +export function allHopsObserved(steps: { observed?: ObservedCall }[]): boolean { + if (steps.length < 2) return false; + return steps.slice(1).every((step) => step.observed !== undefined); +} + +/* The most recently seen observation across steps — names the freshness + * footer. Null when nothing was observed. */ +export function newestObserved( + steps: { observed?: ObservedCall }[], +): ObservedCall | null { + let newest: ObservedCall | null = null; + for (const step of steps) { + if (step.observed && (!newest || step.observed.last_seen > newest.last_seen)) { + newest = step.observed; + } + } + return newest; +} + +/* Title text for the marker chip: "ran in pytest 2026-08-27 · last 2026-08-27". */ +export function observedTitle(observed: ObservedCall): string { + return `ran in ${observed.label} · last ${observed.last_seen.slice(0, 10)}`; +} diff --git a/graph-ui/src/lib/questions.ts b/graph-ui/src/lib/questions.ts index 6c8be3c4f..78bfca76f 100644 --- a/graph-ui/src/lib/questions.ts +++ b/graph-ui/src/lib/questions.ts @@ -59,7 +59,8 @@ export const QUESTION_FAMILIES: QuestionFamily[] = [ question: "What code caused this behavior?", status: "partial", tab: "flows", - gap: "Flows are static journeys; observed executions (traces) are not surfaced in the UI yet", + hint: "Flows tab — ▶ observed markers on trace hops and flow steps show what actually ran", + gap: "Flows and traces now mark observed vs possible hops from ingested runtime traces; automatic trace capture and failure reproduction are not built", }, { id: "F6", diff --git a/graph-ui/src/wiki/entries.ts b/graph-ui/src/wiki/entries.ts index ce791fcf2..798c1300e 100644 --- a/graph-ui/src/wiki/entries.ts +++ b/graph-ui/src/wiki/entries.ts @@ -173,6 +173,25 @@ export const WIKI_ENTRIES: WikiEntry[] = [ "Reflection, code generation, and runtime dispatch are not modelled. Coverage is best-effort.", appearsIn: ["Flows", "Trace", "Symbol connections"], }, + { + id: "M-OBSERVED", + slug: "observed", + term: "observed", + tier: "first-class", + sentence: + "A call that actually fired in a recorded run — observed, not merely possible from static analysis.", + why: "Static edges say what could happen; observation says what did. An observed marker on a speculative edge is runtime evidence the resolver got it right.", + computedParts: [ + "ingest_traces call paths, stored by qualified name per run label", + ], + caps: [ + "capped: 200k observed pairs per project — whole runs age out together, oldest first", + ], + notCovered: + "Absence of observation never means dead or unused — a run covers only what it exercised. Counts survive reindexing (name-keyed), but renamed symbols start fresh.", + appearsIn: ["Flows", "Trace"], + seeAlso: ["confidence", "dead-candidate"], + }, { id: "M-DEAD", slug: "dead-candidate", diff --git a/graph-ui/tsconfig.tsbuildinfo b/graph-ui/tsconfig.tsbuildinfo index 03dbfbc07..2dc75298f 100644 --- a/graph-ui/tsconfig.tsbuildinfo +++ b/graph-ui/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/changestab.tsx","./src/components/commandk.tsx","./src/components/controltab.tsx","./src/components/dashboardtab.tsx","./src/components/displaysettingsmenu.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/flowstab.tsx","./src/components/graphloader.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.deadcode.test.tsx","./src/components/graphtab.filters.test.tsx","./src/components/graphtab.regions.test.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/halolayer.tsx","./src/components/metricchip.tsx","./src/components/minimap.tsx","./src/components/missedcallout.tsx","./src/components/modulestab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.showmore.test.tsx","./src/components/nodedetailpanel.test.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/overviewtab.tsx","./src/components/projectcard.tsx","./src/components/projectswitcher.tsx","./src/components/promptbasket.tsx","./src/components/regionpanel.tsx","./src/components/regiontooltip.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/symboltab.tsx","./src/components/tabbar.tsx","./src/components/triggertree.tsx","./src/components/wikihovercard.test.tsx","./src/components/wikihovercard.tsx","./src/components/wikipanel.test.tsx","./src/components/wikipanel.tsx","./src/components/wikitier.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/atlas.test.ts","./src/lib/atlas.ts","./src/lib/colors.ts","./src/lib/complexity.test.ts","./src/lib/complexity.ts","./src/lib/composer.ts","./src/lib/density.test.ts","./src/lib/density.ts","./src/lib/findings.test.ts","./src/lib/findings.ts","./src/lib/firstread.ts","./src/lib/flowgroup.test.ts","./src/lib/flowgroup.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/impact.test.ts","./src/lib/impact.ts","./src/lib/questions.test.ts","./src/lib/questions.ts","./src/lib/rationale.test.ts","./src/lib/rationale.ts","./src/lib/regions.test.ts","./src/lib/regions.ts","./src/lib/treemap.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/lib/why.test.ts","./src/lib/why.ts","./src/lib/whyfetch.ts","./src/test/setup.ts","./src/wiki/entries.test.ts","./src/wiki/entries.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/changestab.tsx","./src/components/commandk.tsx","./src/components/controltab.tsx","./src/components/dashboardtab.tsx","./src/components/displaysettingsmenu.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/flowstab.observed.test.tsx","./src/components/flowstab.tsx","./src/components/graphloader.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.deadcode.test.tsx","./src/components/graphtab.filters.test.tsx","./src/components/graphtab.regions.test.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/halolayer.tsx","./src/components/metricchip.tsx","./src/components/minimap.tsx","./src/components/missedcallout.tsx","./src/components/modulestab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.showmore.test.tsx","./src/components/nodedetailpanel.test.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/overviewtab.tsx","./src/components/projectcard.tsx","./src/components/projectswitcher.tsx","./src/components/promptbasket.tsx","./src/components/regionpanel.tsx","./src/components/regiontooltip.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/symboltab.tsx","./src/components/tabbar.tsx","./src/components/triggertree.tsx","./src/components/wikihovercard.test.tsx","./src/components/wikihovercard.tsx","./src/components/wikipanel.test.tsx","./src/components/wikipanel.tsx","./src/components/wikitier.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/atlas.test.ts","./src/lib/atlas.ts","./src/lib/colors.ts","./src/lib/complexity.test.ts","./src/lib/complexity.ts","./src/lib/composer.ts","./src/lib/density.test.ts","./src/lib/density.ts","./src/lib/findings.test.ts","./src/lib/findings.ts","./src/lib/firstread.ts","./src/lib/flowgroup.test.ts","./src/lib/flowgroup.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/impact.test.ts","./src/lib/impact.ts","./src/lib/observed.test.ts","./src/lib/observed.ts","./src/lib/questions.test.ts","./src/lib/questions.ts","./src/lib/rationale.test.ts","./src/lib/rationale.ts","./src/lib/regions.test.ts","./src/lib/regions.ts","./src/lib/treemap.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/lib/why.test.ts","./src/lib/why.ts","./src/lib/whyfetch.ts","./src/test/setup.ts","./src/wiki/entries.test.ts","./src/wiki/entries.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index eb55e6ce6..d948d757b 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -11147,7 +11147,7 @@ static char *adr_read_legacy_file(const char *root_path) { /* resolve_store opens file-backed projects query-only. A mutation must release * that reader before opening a dedicated writer because atomic publication uses * a self-contained DELETE-mode database that switches back to WAL on write. */ -static cbm_store_t *open_adr_store_for_write(cbm_mcp_server_t *srv, cbm_store_t *resolved, +static cbm_store_t *open_store_for_write(cbm_mcp_server_t *srv, cbm_store_t *resolved, cbm_store_t **owned_rw) { if (!srv || !resolved || !owned_rw) { return NULL; @@ -11248,7 +11248,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { cbm_store_t *store = resolved; cbm_store_t *owned_rw = NULL; if (write_request) { - store = open_adr_store_for_write(srv, resolved, &owned_rw); + store = open_store_for_write(srv, resolved, &owned_rw); if (!store) { if (mutation_held) { mcp_project_mutation_end(srv, project); @@ -11285,7 +11285,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { resolved = resolve_store_internal(srv, project, true, false, NULL); } if (resolved) { - store = open_adr_store_for_write(srv, resolved, &owned_rw); + store = open_store_for_write(srv, resolved, &owned_rw); if (store) { have_adr = (cbm_store_adr_get(store, project, &adr) == CBM_STORE_OK); if (!have_adr && @@ -11384,12 +11384,25 @@ static char *handle_ingest_traces(cbm_mcp_server_t *srv, const char *args) { if (!label || !label[0]) label = "unlabeled"; + /* resolve_store opens file-backed projects query-only (and skips schema + * DDL); observation writes need the dedicated read-write connection — + * whose open also creates the observed_* tables in pre-existing DBs. */ + cbm_store_t *owned_rw = NULL; + store = open_store_for_write(srv, store, &owned_rw); + if (!store) { + yyjson_doc_free(adoc); + free(project); + return cbm_mcp_text_result("could not open the project store for writing", true); + } + struct sqlite3 *db = cbm_store_get_db(store); sqlite3_stmt *exists = NULL; if (!db || sqlite3_prepare_v2(db, "SELECT 1 FROM nodes WHERE project=?1 AND qualified_name=?2 " "LIMIT 1", -1, &exists, NULL) != SQLITE_OK) { + if (owned_rw) + cbm_store_close(owned_rw); yyjson_doc_free(adoc); free(project); return cbm_mcp_text_result("store is not queryable", true); @@ -11518,6 +11531,8 @@ static char *handle_ingest_traces(cbm_mcp_server_t *srv, const char *args) { char *json = yy_doc_to_str(doc); yyjson_mut_doc_free(doc); yyjson_doc_free(adoc); + if (owned_rw) + cbm_store_close(owned_rw); free(project); char *result = cbm_mcp_text_result(json, false);