Files
podman/docs/live-ui-spec.md
sb-iam 7d4029a046 docs(spec): note the shadcn/ruixen unifying-template rule in §4b
Chrome composes from @/components/ui/* primitives (npx shadcn add
ruixen.com/r/[component]); only the SVG canvas is bespoke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:49:10 -07:00

31 KiB
Raw Permalink Blame History

Real Continual-Learning UI — Spec

Owner: graph data + visualization (live data). Status: demo-backed graph shipped (docs/graph.md); this spec makes it REAL. Extends docs/graph.md — does not duplicate it. Same files, same contracts (shared/src/graph.ts, PodGraph, the two collections, the two routes). This spec only adds: a live materializer behind loadPodGraph, an outcomes aggregation, a ws push of GRAPH_DIRTY, and the per-pod GraphView wiring. Everything in graph.md (node/edge kinds, $graphLookup, demo fallback) remains the contract.

0. Purpose & framing

The demo graph already renders the "it learned" story (createDemoPodGraph → Karti owns auth, learned_from edge, 86% accept rate). The problem: none of it is realteam_model/graph_nodes/graph_edges are never written (seedGraph has zero callers), so loadPodGraph always returns the hardcoded demo. The graph looks alive but is a poster.

This spec makes the same poster a live render of the 6 collections the agent actually writes (pods, engineer_states, observations, collisions, interventions, outcomes), so the continual-learning loop the judges see is backed by data the pipeline produced this session.

The visible self-improving loop, before → after:

  • Before: Two engineers edit auth.ts, one unpushed. A collisions doc is written, an interventions doc (status pending). The graph grows a red collision triangle and a warns edge to the intervention diamond. Copy: "new — first time PodMan saw this path."
  • After: The human clicks Accept → POST /api/outcome writes {accepted:true, wasRealCollision:true}. The materializer turns that outcome into a learned_from edge (intervention → engineer, label learned: owns auth.ts), flips the engineer node to status:'learned', and bumps the Learned owners metric. Copy on the next similar collision: "I've seen this before — last time the team accepted sync PR" (driven by collisions.memorySignature recall, already live).

Why this is not a dashboard (hard constraint): it stays the secondary, toggle-opened view behind the pods list (graph.md), keeps a single-canvas SVG themed to match the app's shadcn UI (one graph, not a grid of charts), and every visible element is anchored to a live write + an action loop. The metrics rail is 3 numbers derived from real counts, not a wall of KPIs. The hero remains the intervention card in PodView; this view exists only to make "PodMan got better" legible in 10 seconds.

R1 — Revisions (resolves PR #3 review)

Revised after review. The five findings and resolutions (the sections below reflect these):

  • P1 — learned owner was guessed, not supervised → fixed by a contract change. The Accept flow now carries the confirmed owner: InterventionOutcome (shared/src/messages.ts) gains learnedOwner?: string + file?: string, and frontend/src/livekit/useInterventions.ts sends learnedOwner (the confirming engineer) + file (collision.file). On accepted && wasRealCollision, POST /api/outcome writes team_model.ownership[file] = learnedOwner — the existing-but-never-written TeamModel.ownership type. The materializer draws owns/learned_from edges from team_model.ownership (authoritative); the most-recent-observation owner survives only as a clearly-labelled low-confidence fallback. Ownership is now stored, not inferred.
  • P1 — changedFiles aren't clean paths → fixed in the materializer. They are raw git status --short lines ("M src/auth.ts", "?? x", "R a -> b"). live.ts runs parseGitStatusPath() (strip the XY code; post--> target for renames) then normalizeFile() so file ids match collisions.file.
  • P2 — ws bridge made concrete. GRAPH_DIRTY (must-have) is emitted inside the Express process on POST /api/outcome (no bridge). The agent is a separate process publishing to LiveKit; for the nice-to-have instant COLLISION/VOICE_CUE push, the agent opens a ws client to ws://127.0.0.1:${PORT}/api/events and forwards them. If unreachable, the 5s poll covers it. Instant push = nice-to-have; poll = floor.
  • P2 — demo metrics shown as live → fixed with a source marker. PodGraph gains source: 'live' | 'demo' (createDemoPodGraph='demo', materializer='live'). When source==='demo' or live-with-zero outcomes, GraphView shows a "demo · waiting for first observation" badge and renders metric values as , so 5 / 2 / 86% never reads as live.
  • P3 — PLAN-before-code inconsistency → softened. This PR is spec-only; the docs/PLAN.md task entry + docs/graph.md cross-link land with the first implementation PR (PLAN.md is hot/concurrent; the task ships alongside its code).

Contract changes (small, additive): shared/src/messages.ts (InterventionOutcome +learnedOwner/file; TeamModel.ownership now written), shared/src/graph.ts (PodGraph.source), frontend/src/livekit/useInterventions.ts (Accept sends owner+file).

R2 — Theme (matches the app)

The app shipped as light shadcn; the shipped GraphView was hardcoded dark-Bauhaus and clashed. Resolution: re-theme GraphView.tsx to shadcn tokens (var(--card) / --foreground / --muted-foreground / --border, --chart-1..5 for node hues) so it is theme-aware — light like the rest of the app today, dark if the app toggles — and reads as native. The functional encoding (geometric node shapes, per-kind colors, the 3-number metrics rail) is unchanged. This re-theme ships as its own small change to the live component (feat/team-memory-theme), independent of the live-data work.

1. Live data → graph mapping

The materializer (backend/src/graph/live.ts, §3) reads these collections per podId and emits PodGraph (shared/src/graph.ts). Node ids stay stable so realtime refreshes don't reshuffle: engineer:<name>, file:<normalizedFile>, collision:<collision.id>, intervention:<intervention.id>.

Source collection Fields read Produces
pods members[], name, repo Baseline engineer: nodes for every roster member (so the graph isn't empty pre-activity). summary = pod repo.
engineer_states (via getGitStates) name, changedFiles[], branch, recentCommit, gitUpdatedAt engineer:<name> node status:'risk' when changedFiles.length>0. changedFiles[] are raw git status --short lines (e.g. "M src/auth.ts", "?? x.ts", "R a -> b"), NOT clean paths (R1)live.ts runs parseGitStatusPath() (strip the XY code; post--> target for renames) then normalizeFile() so file ids match collisions.file. One file: node per parsed path; editing edge engineer→file, strength 0.6. Engineer summary = "N changed files on <branch>".
observations (EngineerContext) engineerId, currentFile, currentSymbol, activity, confidence, observedAt engineer:<engineerId> node status:'active' if a observedAt within last 60s exists; file:<currentFile> node; editing edge engineer→file, strength = confidence (Gemini meter). Most-recent observedAt gives a file's de-facto owner — used only as the low-confidence fallback for the owns edge when team_model.ownership is empty (R1). Edge label = activity.
collisions (Collision) id, file, symbol, engineers[], severity, detectedAt, memorySignature One collision:<id> node, kind:'collision', status:'risk', weight by severity (info0.4/warn0.7/critical1.0). collides edge per name in engineers[] (engineer→collision, strength from severity). touches edge file:<file>→collision. A summary badge "seen before" when memorySignature matched a prior collision (severity escalated to critical — already the live recall signal).
interventions (Intervention) id, collisionId, kind, suggestedAction.kind, status, createdAt One intervention:<id> diamond. warns edge collision:<collisionId>→intervention, label from suggestedAction.kind (open_sync_pr"sync PR", ping_teammate"ping", none"watch"). Color cannot come from status (always pending — never updated), so it is joined to outcomes (next row).
outcomes (InterventionOutcome) interventionId, wasRealCollision, accepted, recordedAt The supervised learning signal (R1). On accepted && wasRealCollision the outcome carries learnedOwner + file; the server writes team_model.ownership[file]=learnedOwner. The materializer reads team_model.ownership (authoritative) to draw the owns edge and a learned_from edge intervention:<id>engineer:<learnedOwner> (label: learned: owns <file>, strength 0.6), flipping that engineer + intervention to status:'learned'. accepted===false → intervention status:'stable' ("dismissed"). Observation-based ownership is only a low-confidence fallback when team_model.ownership is empty — never the primary signal.

Metrics rail (PodGraphMetric[], replacing demo's hardcoded 5 / 2 / 86%), computed live in live.ts:

label value detail Source
Learned owners count of accepted+real outcomes "Ownership edges retained from accepted interventions." outcomes aggregation
Open risk paths count of collisions with engineers.length>=2 and any colliding engineer has unpushed (engineer_states.changedFiles non-empty) "Files with converging editors and unpushed work." collisions × engineer_states
Accept rate round(accepted / total outcomes * 100)% ("—" when total 0) "Interventions accepted this session." outcomes aggregation

x/y layout: live.ts runs a deterministic column layout (engineers x≈78, files x≈300, collisions x≈470, interventions/features x≈620; y spread evenly per column within the 0..472 viewBox) so the existing SVG renders unchanged.

2. The visible workflow (observe → store → predict → outcome → adapt)

The graph view narrates the same loop the agent runs, mapped to what the viewer sees:

  1. Observe (observations, ~1fps; engineer_states, 15s): engineer nodes light active; editing edges thicken with Gemini confidence. A small caption under the canvas: "Yahya — editing auth.ts (unpushed)" from the latest observation + git chip.
  2. Store / Predict (collisions + vector recall): when ≥2 engineers + unpushed, a red collision triangle and collides/touches edges appear. If memorySignature recall hit (severity critical), the node carries a "seen before" badge — the self-improvement signal.
  3. Intervene (interventions): a warns edge draws to a new intervention diamond labeled by suggestedAction.kind. The view shows a "PodMan is speaking" pulse on that edge when a VOICE_CUE arrives over ws (§5).
  4. Outcome (POST /api/outcome from the PodView card): the moment the human clicks Accept.
  5. Adapt (outcomeslearned_from): on the next graph refresh, a dashed purple learned_from edge animates in, the engineer node flips to learned (gold/locked), and Learned owners + Accept rate tick up.

The live "money moment" (sequence the demo lands): collision detected → red triangle + edges appear and PodMan speaks (pulse) → human clicks Accept on the card in PodView → switch to Team memory → the learned_from edge to engineer:karti (learned: owns auth.ts) is now present, the engineer is gold, metrics rose. A second collision on the same signature renders with the "seen before" badge. That single before→after transition, all from collections written this session, is the continual-learning proof.

3. Backend changes

All additive / behind existing signatures — graph.md contracts unchanged.

3a. Live materializer — backend/src/graph/live.ts (NEW)

export async function materializePodGraph(podId: string): Promise<PodGraph>. Reads the 6 collections via collections() + getGitStates(podId) (memory/db.ts), builds nodes/edges/metrics per §1, runs the column layout, returns PodGraph. Pure-read; never writes. Wrapped so any Mongo error throws to the caller (which falls back to demo, §3b). Helpers: parseGitStatusPath() (strip the git status --short XY code; take the post--> target for renames) applied to engineer_states.changedFiles, then normalizeFile() reused from collision/detector.ts so file ids match collisions.file exactly. Ownership for owns/learned_from edges is read from team_model.ownership (authoritative); observation-based ownership is only a marked low-confidence fallback.

3b. loadPodGraphbackend/src/graph/store.ts (MODIFY)

Replace the body so it prefers live, then team_model, then demo:

1. const graph = await materializePodGraph(podId)
2. if (graph.nodes.length > <BASELINE>) return graph   // real activity exists
3. const doc = team_model.findOne({podId}); if (doc?.graph) return doc.graph
4. return createDemoPodGraph(podId)                      // demo-stability fallback

<BASELINE> = the count of pure roster engineer: nodes (no files/collisions). If only roster nodes exist (no observations/collisions yet) the view shows demo so the canvas is never empty mid-demo (§5). Signature, route, and demo fallback all unchanged.

3c. Keep $graphLookup real — mirror into graph_nodes/graph_edges

Add export async function materializeAndSeed(podId) in store.ts: calls materializePodGraph, then reuses seedGraph's existing upsert/deleteMany/insertMany block to write team_model.graph + graph_nodes + graph_edges from the live graph (today seedGraph writes the demo graph from createDemoPodGraph; this variant takes the live one). Called (a) on every POST /api/outcome (so reachFrom reflects the new learned_from edge), and (b) lazily inside the graph route after loadPodGraph returns a live graph. This is the only way reachFrom//graph/reach/:node stops returning empty.

3d. Routes — backend/src/server.ts (MODIFY, additive only)

  • GET /api/pods/:id/graph — unchanged signature; now returns live graph via §3b.
  • GET /api/pods/:id/graph/reach/:node — unchanged; now non-empty once §3c runs.
  • GET /api/pods/:id/graph/metrics (NEW, small) — returns just PodGraphMetric[] (the outcomes aggregation: learned owners, open risk paths, accept rate) so the rail can poll cheaply without re-sending the whole graph. Backed by a db.collection('outcomes').aggregate group on podId (count, $sum accepted).
  • POST /api/outcome (MODIFY): after recordOutcome, when accepted && wasRealCollision write team_model.ownership[outcome.file] = outcome.learnedOwner (the supervised signal — TeamModel type, upsert on podId), then call materializeAndSeed(podId) (best-effort, never throws) and broadcast {type:'GRAPH_DIRTY', podId} to ws /api/events clients (reuse the existing clients set / c.send). This is the only place the loop closes.

3e. Realtime push — ws /api/events (MODIFY)

The relay already fans out any JSON. GRAPH_DIRTY needs no bridge — it is emitted from inside the Express process on POST /api/outcome, so it reaches ws clients directly. The agent runs in a SEPARATE process (dev:agent / agent.ts) and publishes COLLISION/VOICE_CUE on the LiveKit data channel, which the server's ws clients never see. Concrete bridge for the (nice-to-have) instant push: the agent opens a WebSocket client to ws://127.0.0.1:${PORT}/api/events and forwards the same COLLISION/VOICE_CUE JSON; the relay fans it out. If the agent can't reach the API, the 5s poll (§5) covers it (≤5s). This instant push is explicitly nice-to-have (§6); the poll is the floor.

4. Frontend changes

4a. Per-pod entry point — frontend/src/App.tsx (MODIFY)

The "Team memory" button currently passes pods[0]?.id ?? 'demo-pod' (line 271) — wrong pod for a multi-pod demo. Change to open the graph for the pod in context: add a BrainCircuitIcon action on each PodCard (onOpenGraph(pod.id)) wired to setGraphPodId(pod.id), and keep the header button as a fallback that opens the selected/first live pod (the one with presence). The router slot (if (graphPodId) return <GraphView podId={graphPodId} …/>, line 237-239) is unchanged.

4b. GraphView.tsx — match the app's shadcn theme, add realtime

Themed to match the command-center (R2). The hardcoded dark .pm-* palette is replaced with shadcn tokens (var(--card) / --foreground / --muted-foreground / --border) so Team Memory is theme-aware (renders light like the rest of the app today, follows dark mode if the app toggles) and reads as native, not a dark island. Unifying-template rule: the chrome composes from the shadcn/ruixen registry primitives — Button, Badge, and the app's Tailwind utility patterns (StatPill/BriefLine-style) from @/components/ui/*; add any new primitive with npx shadcn@latest add "https://ruixen.com/r/[component]". Only the SVG node-link canvas is bespoke (3 SVG-only CSS rules). The functional encoding stays: geometric node shapes (engineer square / file square-outline / feature circle / collision triangle / intervention diamond) and per-kind light-readable hues. What changes:

  • Realtime refresh: open a ws to /api/events on mount; on {type:'GRAPH_DIRTY', podId} (matching this pod) or COLLISION/VOICE_CUE, re-call fetchPodGraph(podId) (and fetchGraphMetrics). Also a 5s poll of fetchPodGraph as the floor (mirrors App.tsx's existing 5s presence/memory poll) so it's live even if ws drops. New incoming nodes/edges fade in (CSS opacity transition on <line>/shape); learned_from edges animate the dashed stroke.
  • "new" vs "seen before" copy in the detail rail from collision node summary badge (§1).
  • Speaking pulse on the warns edge when a VOICE_CUE lands.

4c. frontend/src/lib/graph.ts (MODIFY)

  • Fix BACKEND_URL to follow lib/api.ts's resolution (empty string in prod) instead of hardcoding localhost:8787.
  • Add fetchGraphMetrics(podId)GET /api/pods/:id/graph/metrics.
  • Add openGraphEvents(podId, onDirty) → thin WebSocket('/api/events') subscription helper (reused by GraphView).

4d. Loading / empty / error states (collections empty is the common real case)

  • Loading: existing on-mount spinner; keep.
  • Empty (no activity yet): §3b returns the demo graph so the canvas is never blank — but PodGraph now carries source: 'live' | 'demo' (R1): createDemoPodGraph sets 'demo', the materializer 'live'. When graph.source==='demo' (or 'live' with metrics total 0), GraphView renders a "demo · waiting for first observation" badge and suppresses the metric values (shows ) so the baked 5 / 2 / 86% never reads as live. No empty-grid placeholder.
  • Error / backend down: fetchPodGraph rejects → render the last good graph if any, else the demo graph rendered client-side is not available; show a single-line .pm- error chip "memory offline — retrying" and keep polling. ws errors are swallowed (poll covers it).

5. Realtime & data freshness

Surface Transport Cadence
Engineer active/file edits ws COLLISION passthrough + 5s fetchPodGraph poll ~1fps source data, surfaced ≤5s
Git chip (changed files / branch) folded into 5s graph poll (engineer_states) 15s underlying write
Collision / intervention nodes ws COLLISION (instant) → triggers refetch instant on event
Speaking pulse ws VOICE_CUE instant
learned_from edge + metrics ws GRAPH_DIRTY on POST /api/outcome → refetch instant on Accept

Polling is the floor, ws is the accelerator — never gate the canvas on ws. Demo-stability fallback: if the live materializer yields ≤ baseline nodes or Mongo is unreachable, the route serves createDemoPodGraph (§3b) so the toggle always shows a coherent graph on stage. The Accept→learned_from beat is driven by POST /api/outcomematerializeAndSeedGRAPH_DIRTY, the one path that must be solid.

6. Demo path

Must-have (the 3-min money moment):

  • materializePodGraph reading collisions + interventions + outcomes + engineer_states so the graph reflects this session.
  • POST /api/outcomematerializeAndSeed + GRAPH_DIRTY; GraphView refetches and the learned_from edge + gold node + risen metrics appear after Accept.
  • Live metrics rail (Learned owners / Open risk paths / Accept rate) from the outcomes aggregation.
  • "seen before" badge from collisions.memorySignature (already live) on the second collision.
  • Demo-graph fallback when collections are empty (stage safety).

Nice-to-have:

  • ws VOICE_CUE speaking pulse on the warns edge.
  • /graph/reach/:node lit risk-path walk ($graphLookup) once materializeAndSeed populates graph_edges.
  • Fade/stroke animations on incoming edges.
  • Per-PodCard graph entry button.

Cut if behind (per CLAUDE.md 12h box):

  • Vector-search dependency for recall (exact memorySignature recall already covers the learning beat).
  • Any new chart primitive / recharts — keep the SVG.
  • Auth/pod-scoping on ws /api/events.
  • Historical/time-scrubbed graph; only "now" is needed.

The 3-min beat (extends PLAN.md §9, ends in this view): IDE with unpushed auth.ts → live caption → second engineer opens same file → COLLISION card in PodView (named teammate + sync PR action) → Hermes voice → human clicks Accept → toggle Team memory → the learned_from "learned: Karti owns auth.ts" edge is now real, metrics rose → second collision shows "I've seen this before" → close on the public repo PR URL + the live metrics.

7. Files & tasks (documentation-first)

Per CLAUDE.md gate: the spec lands before code. This PR is spec-only. The docs/PLAN.md task entry (Pxx — Live continual-learning graph, Files list below) + the docs/graph.md "Live data backing" cross-link land with the first implementation PRPLAN.md is a hot, concurrently-edited file, so the task ships alongside the code that satisfies it rather than as a separate doc-only edit that would conflict.

Create:

  • backend/src/graph/live.tsmaterializePodGraph(podId), normalizeFile, metrics aggregation, column layout. (Task: live materializer)

Modify:

  • docs/graph.md — append a "Live data backing" section pointing to this spec (the §1 mapping table + live.ts); change the "Demo-first plan" step 3 ("Swap loadPodGraph…") to "done via materializePodGraph." (documentation-first)
  • docs/PLAN.md — add the task + Files list. (documentation-first)
  • backend/src/graph/store.tsloadPodGraph prefers live (§3b); add materializeAndSeed(podId) (§3c). (Task: live wiring + $graphLookup)
  • backend/src/server.tsPOST /api/outcome calls materializeAndSeed + broadcasts GRAPH_DIRTY; new GET /api/pods/:id/graph/metrics; ws passthrough of COLLISION/VOICE_CUE/GRAPH_DIRTY. (Task: routes + realtime)
  • backend/src/agent/podman.ts — also emit COLLISION/VOICE_CUE onto ws /api/events (mirror of the LiveKit data-channel publish) so the dashboard-level GraphView reacts. (Task: realtime mirror)
  • frontend/src/lib/graph.tsBACKEND_URL resolution fix; fetchGraphMetrics; openGraphEvents. (Task: client)
  • shared/src/messages.tsInterventionOutcome +learnedOwner?/file?; TeamModel.ownership now written (R1). (Task: supervised signal)
  • shared/src/graph.tsPodGraph +source: 'live' | 'demo' (R1). (Task: live/demo honesty)
  • frontend/src/livekit/useInterventions.ts — Accept postOutcome sends learnedOwner + file (R1). (Task: supervised signal)
  • frontend/src/components/GraphView.tsx — re-theme to shadcn tokens (theme-aware, R2); ws subscription + 5s poll + animations + "new/seen-before" rail copy + speaking pulse. (Task: client)
  • frontend/src/App.tsx — per-pod graph entry; header button opens live/selected pod not pods[0]. (Task: entry point)
  • frontend/src/components/PodCard.tsxonOpenGraph(pod.id) action. (Task: entry point)

Contract changes (small, additive — R1): shared/src/messages.ts (InterventionOutcome +learnedOwner/file; TeamModel.ownership now written), shared/src/graph.ts (PodGraph +source), frontend/src/livekit/useInterventions.ts (Accept sends owner+file). Still unchanged: the two graph routes' signatures, node/edge kinds, createDemoPodGraph (kept as fallback), seedGraph/graph:seed (kept; materializeAndSeed reuses its write block).

8. Risks & open questions

  • engineer_states is keyed by name; observations by engineerId; collisions by names in engineers[]. The materializer must reconcile these to one engineer:<name> node. Assumption (from data map): LiveKit identity == --name == engineer name. If they diverge, edges will orphan. Mitigation: key all engineer nodes off pods.members and match case-insensitively; drop unmatched.
  • outcomes had no owner identity (review P1) — resolved (R1): the Accept payload now carries learnedOwner + file, persisted to team_model.ownership[file], so the learned_from/owns edges are supervised, not guessed. The outcome → intervention → collision join + most-recent-observation owner remains only as a clearly-marked low-confidence fallback when learnedOwner/team_model.ownership is absent.
  • observations TTL may not fire (init.ts indexes observedAt as Date but it's stored as ISO string) — so "active" windowing must compare parsed ISO timestamps in live.ts, not rely on TTL eviction; old observations could otherwise inflate "active." Open: cap to most-recent observation per (engineerId,file).
  • intervention.status never advances past pending — confirmed; the UI must color interventions from the outcomes join, never from status. Already handled in §1, but worth a one-line code comment so a future dev doesn't "fix" it by reading status.
  • ws /api/events has no pod-scopingGRAPH_DIRTY carries podId and the client filters; acceptable for the demo, flagged as the known shortcut.
  • Open question: should materializeAndSeed run on every POST /api/outcome (simple, slightly heavy) or be debounced? For a 3-engineer demo, run inline; revisit only if outcome volume spikes.
  • Open question: when both a live materializePodGraph graph and a team_model.graph exist, live wins (§3b). Confirm no flow expects team_model.graph to be authoritative — currently nothing writes it except the dead seedGraph, so live-wins is safe.