feat(ui): observed vs possible — runtime markers in flows and traces

Trace hops and flow steps that actually fired render '▶ observed ×N'
with the run label and date; a fully-observed trace says so in one
line, and the footer states the honesty rule: unmarked hops are
possible, not dead — runs only cover what they exercised. The wiki
gains the observed entry (first-class, with its caps), and F5's gap
narrows to what is still missing (automatic capture).

Live-smoke fix on the C side: resolve_store hands handlers a
query-only connection, so ingest writes silently no-op'd against real
project stores — ingest_traces now takes the dedicated read-write
connection (open_store_for_write, né open_adr_store_for_write), whose
open also creates the observed tables in pre-existing databases.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-08-27 21:46:24 +02:00
parent baf0df8e91
commit ce2e406839
10 changed files with 244 additions and 6 deletions
+1
View File
@@ -289,6 +289,7 @@ export function App() {
navigate({ tab: "flows", flow: id !== null ? String(id) : null })
}
onOpenSymbol={openSymbol}
onOpenWiki={setWikiSlug}
/>
) : activeTab === "changes" && selectedProject ? (
<ChangesTab project={selectedProject} onOpenSymbol={openSymbol} />
@@ -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(<ObservedChip observed={observed} />);
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(<ObservedChip observed={observed} onOpenWiki={onOpenWiki} />);
fireEvent.click(screen.getByRole("button", { name: "observed" }));
expect(onOpenWiki).toHaveBeenCalledWith("observed");
});
});
+70 -1
View File
@@ -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 (
<span
className="text-[10px] px-1 rounded-full border border-good/50 text-good/80 font-mono shrink-0"
title={observedTitle(observed)}
>
▶{" "}
{onOpenWiki ? (
<MetricChip slug="observed" onOpen={onOpenWiki}>
observed
</MetricChip>
) : (
"observed"
)}{" "}
×{observed.count}
</span>
);
}
/* 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<FlowsPayload | null>(null);
const [detail, setDetail] = useState<FlowDetail | null>(null);
const [error, setError] = useState<string | null>(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"}
</p>
{allHopsObserved(trace.path ?? []) && (
<p className="text-[11px] text-good/70 mb-1">
This whole path was observed in recorded runs.
</p>
)}
<div className="flex items-center gap-1 flex-wrap">
{(trace.path ?? []).map((step, index) => (
<span key={index} className="flex items-center gap-1 flex-wrap">
@@ -280,9 +331,19 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab
{formatGuard(guard)}
</span>
))}
{step.observed && (
<ObservedChip observed={step.observed} onOpenWiki={onOpenWiki} />
)}
</span>
))}
</div>
{traceNewest && (
<p className="text-[11px] text-foreground/40 mt-1.5">
Runtime markers from recorded runs ({traceNewest.label}).
Unmarked hops are possible, not dead — runs only cover
what they exercised.
</p>
)}
</div>
) : (
<p className="text-[12px] text-foreground/45">
@@ -452,12 +513,20 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab
{formatGuard(guard)}
</span>
))}
{step.observed && <ObservedChip observed={step.observed} />}
<span className="text-[12px] text-foreground/35 truncate ml-auto shrink-0 max-w-[40%]">
{step.file_path}
</span>
</button>
))}
</div>
{detailNewest && (
<p className="text-[11px] text-foreground/40 mt-3">
Runtime markers from recorded runs ({detailNewest.label}).
Unmarked hops are possible, not dead — runs only cover what
they exercised.
</p>
)}
</div>
</ScrollArea>
)}
+11
View File
@@ -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;
}[];
}
+61
View File
@@ -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",
);
});
});
+31
View File
@@ -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)}`;
}
+2 -1
View File
@@ -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",
+19
View File
@@ -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",
+1 -1
View File
@@ -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"}
{"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"}
+18 -3
View File
@@ -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);