From ce2e40683909a247683028430635c13249479f06 Mon Sep 17 00:00:00 2001
From: Martin Vogel
+ This whole path was observed in recorded runs. +
+ )}+ 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 &&
+ 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);