From 85ab8da598b92d5d7a6b2400da0cabbac7bdbcf6 Mon Sep 17 00:00:00 2001 From: sb-iam <59984144+sb-iam@users.noreply.github.com> Date: Sat, 27 Jun 2026 20:44:50 -0700 Subject: [PATCH 1/3] feat(graph): live materializer + light shadcn theme (real-data glue) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/src/graph/live.ts: materializePodGraph builds the graph from the real collections (pods, engineer_states, observations, collisions, interventions, outcomes) instead of the demo seed. Parses git-status paths, fuses git+vision, draws collides/warns/learned_from, computes live metrics + a column layout. - store.ts: loadPodGraph now prefers live → seeded team_model.graph → demo. - GraphView.tsx: light/shadcn theme (from the team-memory-theme work), composed from the ruixen primitives; node-shape encoding unchanged. - docs/graph.md: live data→graph mapping + fallback order. Typechecks clean. Not yet runtime-verified end-to-end (Atlas creds rotated again). Co-Authored-By: Claude Opus 4.8 --- backend/src/graph/live.ts | 287 ++++++++++++++++++ backend/src/graph/store.ts | 10 + docs/graph.md | 29 +- frontend/src/components/GraphView.tsx | 401 +++++++++++++------------- 4 files changed, 515 insertions(+), 212 deletions(-) create mode 100644 backend/src/graph/live.ts diff --git a/backend/src/graph/live.ts b/backend/src/graph/live.ts new file mode 100644 index 0000000..377b1b3 --- /dev/null +++ b/backend/src/graph/live.ts @@ -0,0 +1,287 @@ +import type { + PodGraph, + PodGraphNode, + PodGraphEdge, + PodGraphMetric, + PodGraphNodeKind, + PodGraphEdgeKind, + PodGraphNodeStatus, +} from '@podman/shared'; +import { collections, getGitStates, getDb } from '../memory/db.js'; + +/** + * Live materializer: build a pod's continual-learning graph from the real + * collections the agent writes (pods, engineer_states, observations, collisions, + * interventions, outcomes) — NOT the hardcoded demo. See docs/live-ui-spec.md §1. + * + * Pure-read and best-effort. Returns `null` when there is no real activity yet + * (only bare roster), so `loadPodGraph` can fall back to the demo graph. + */ + +const ACTIVE_WINDOW_MS = 90_000; +const MAX_OBSERVATIONS = 250; + +/** Strip a `git status --short` XY code (and rename `old -> new`) to a clean path. */ +export function parseGitStatusPath(line: string): string { + let s = line.trim(); + const arrow = s.indexOf(' -> '); + if (arrow !== -1) s = s.slice(arrow + 4); + else s = s.replace(/^[ACDMRTU?!]{1,2}\s+/, ''); + return normalizeFile(s); +} + +/** Normalize a file path so vision (`collisions.file`) and git paths match. */ +export function normalizeFile(f: string): string { + return f + .trim() + .replace(/^["']|["']$/g, '') + .replace(/^\.\//, ''); +} + +const STATUS_RANK: Record = { + stable: 0, + active: 1, + learned: 2, + risk: 3, +}; + +interface Builder { + nodes: Map; + edges: Map; +} + +function nodeKey(kind: PodGraphNodeKind, key: string): string { + return `${kind}:${key}`; +} + +function upsertNode( + b: Builder, + kind: PodGraphNodeKind, + key: string, + patch: Partial>, +): string { + const id = nodeKey(kind, key); + const cur = b.nodes.get(id); + if (!cur) { + b.nodes.set(id, { + id, + kind, + label: patch.label ?? key, + summary: patch.summary ?? '', + weight: patch.weight ?? 0.6, + status: patch.status ?? 'stable', + x: 0, + y: 0, + }); + return id; + } + if (patch.label) cur.label = patch.label; + if (patch.summary) cur.summary = patch.summary; + if (patch.weight && patch.weight > cur.weight) cur.weight = patch.weight; + if (patch.status && STATUS_RANK[patch.status] > STATUS_RANK[cur.status]) + cur.status = patch.status; + return id; +} + +function upsertEdge( + b: Builder, + source: string, + target: string, + kind: PodGraphEdgeKind, + label: string, + strength: number, +): void { + const id = `${kind}:${source}->${target}`; + const cur = b.edges.get(id); + if (!cur) b.edges.set(id, { id, source, target, kind, label, strength }); + else if (strength > cur.strength) cur.strength = strength; +} + +const COLUMN_X: Record = { + engineer: 78, + file: 300, + feature: 360, + collision: 470, + intervention: 622, +}; + +/** Deterministic column layout so the SVG renders stably across refreshes. */ +function layout(nodes: PodGraphNode[]): void { + const byKind = new Map(); + for (const n of nodes) { + const list = byKind.get(n.kind) ?? []; + list.push(n); + byKind.set(n.kind, list); + } + for (const [kind, list] of byKind) { + list.sort((a, b) => a.id.localeCompare(b.id)); + const n = list.length; + list.forEach((node, i) => { + node.x = COLUMN_X[kind]; + node.y = Math.round(((i + 1) / (n + 1)) * 452) + 10; + }); + } +} + +const SEVERITY_WEIGHT: Record = { info: 0.4, warn: 0.7, critical: 1 }; + +export async function materializePodGraph(podId: string): Promise { + const c = await collections(); + const db = await getDb(); + + const [pod, observations, collisionDocs, interventionDocs, outcomeDocs, gitStates] = + await Promise.all([ + c.pods.findOne({ id: podId }), + c.observations.find({ podId }).sort({ observedAt: -1 }).limit(MAX_OBSERVATIONS).toArray(), + c.collisions.find({ podId }).sort({ detectedAt: -1 }).limit(100).toArray(), + c.interventions.find({ podId }).toArray(), + c.outcomes.find({ podId }).toArray(), + getGitStates(podId), + ]); + + // Optional supervised ownership map (team_model.ownership: file -> engineer). + let ownership: Record = {}; + try { + const tm = await db + .collection<{ podId: string; ownership?: Record }>('team_model') + .findOne({ podId }); + ownership = tm?.ownership ?? {}; + } catch { + /* ownership is optional */ + } + + const b: Builder = { nodes: new Map(), edges: new Map() }; + const now = Date.now(); + + // 1. Baseline engineer nodes from the roster. + for (const name of pod?.members ?? []) { + upsertNode(b, 'engineer', name, { label: name }); + } + + // 2. Git truth (engineer_states): unpushed work + edited files. + for (const [name, git] of gitStates) { + const files = git.changedFiles.map(parseGitStatusPath).filter(Boolean); + const eng = upsertNode(b, 'engineer', name, { + label: name, + status: files.length > 0 ? 'risk' : 'active', + summary: files.length + ? `${files.length} changed file(s) on ${git.branch ?? 'detached'}` + : `on ${git.branch ?? 'detached'}`, + weight: 0.7, + }); + for (const file of files) { + const f = upsertNode(b, 'file', file, { label: file, status: 'risk' }); + upsertEdge(b, eng, f, 'editing', 'edits', 0.6); + } + } + + // 3. Vision (observations): who is active and on which file, with confidence. + for (const o of observations) { + if (!o.engineerId) continue; + const recent = o.observedAt && now - new Date(o.observedAt).getTime() < ACTIVE_WINDOW_MS; + const eng = upsertNode(b, 'engineer', o.engineerId, { + label: o.engineerId, + status: recent ? 'active' : undefined, + }); + if (o.currentFile) { + const file = normalizeFile(o.currentFile); + const f = upsertNode(b, 'file', file, { label: file }); + upsertEdge(b, eng, f, 'editing', o.activity ?? 'edits', Math.max(0.4, o.confidence ?? 0.5)); + } + } + + // 4. Collisions: the detected overlaps (fused git + vision). + const collisionById = new Map(); + for (const col of collisionDocs) { + collisionById.set(col.id, col); + const cNode = upsertNode(b, 'collision', col.id, { + label: col.symbol ? `${col.file}#${col.symbol}` : col.file, + status: 'risk', + weight: SEVERITY_WEIGHT[col.severity] ?? 0.7, + summary: `${col.engineers.join(' + ')} on ${col.file}${ + (col as { memorySignature?: string }).memorySignature ? ' · seen before' : '' + }`, + }); + const file = normalizeFile(col.file); + const fNode = upsertNode(b, 'file', file, { label: file, status: 'risk' }); + upsertEdge(b, fNode, cNode, 'touches', 'hot', 0.6); + for (const name of col.engineers) { + const eng = upsertNode(b, 'engineer', name, { label: name }); + upsertEdge(b, eng, cNode, 'collides', 'in', SEVERITY_WEIGHT[col.severity] ?? 0.7); + } + } + + // 5. Interventions: what PodMan offered for each collision. + const interventionById = new Map(); + for (const iv of interventionDocs) { + interventionById.set(iv.id, iv); + const ivNode = upsertNode(b, 'intervention', iv.id, { + label: + iv.suggestedAction?.kind === 'open_sync_pr' + ? 'sync PR' + : iv.suggestedAction?.kind === 'ping_teammate' + ? 'ping' + : 'watch', + summary: iv.message, + }); + if (b.nodes.has(nodeKey('collision', iv.collisionId))) { + upsertEdge(b, nodeKey('collision', iv.collisionId), ivNode, 'warns', 'nudges', 0.85); + } + } + + // 6. Outcomes: the supervised learning signal -> learned_from edges + owns. + for (const out of outcomeDocs) { + if (!out.accepted || !out.wasRealCollision) continue; + const iv = interventionById.get(out.interventionId); + const col = iv ? collisionById.get(iv.collisionId) : collisionById.get(out.collisionId); + if (!col) continue; + const file = normalizeFile(col.file); + const owner = + (out as { learnedOwner?: string }).learnedOwner ?? ownership[file] ?? col.engineers[0]; + if (!owner) continue; + const engNode = upsertNode(b, 'engineer', owner, { label: owner, status: 'learned' }); + const fNode = upsertNode(b, 'file', file, { label: file }); + upsertEdge(b, engNode, fNode, 'owns', 'owns', 0.85); + const ivKey = iv ? nodeKey('intervention', iv.id) : null; + if (ivKey && b.nodes.has(ivKey)) { + upsertNode(b, 'intervention', iv!.id, { status: 'learned' }); + upsertEdge(b, ivKey, engNode, 'learned_from', `learned: owns ${file}`, 0.6); + } + } + + const nodes = [...b.nodes.values()]; + // No real activity beyond the bare roster -> let the caller fall back to demo. + const hasActivity = nodes.some((n) => n.kind !== 'engineer'); + if (!hasActivity) return null; + + layout(nodes); + + const acceptedReal = outcomeDocs.filter((o) => o.accepted && o.wasRealCollision).length; + const totalOutcomes = outcomeDocs.length; + const riskPaths = collisionDocs.filter((col) => new Set(col.engineers).size >= 2).length; + const metrics: PodGraphMetric[] = [ + { + label: 'Learned owners', + value: String(acceptedReal), + detail: 'Ownership retained from accepted interventions.', + }, + { + label: 'Open risk paths', + value: String(riskPaths), + detail: 'Files with two or more converging editors.', + }, + { + label: 'Accept rate', + value: totalOutcomes ? `${Math.round((acceptedReal / totalOutcomes) * 100)}%` : '—', + detail: 'Interventions accepted this session.', + }, + ]; + + return { + podId, + generatedAt: new Date().toISOString(), + nodes, + edges: [...b.edges.values()], + metrics, + }; +} diff --git a/backend/src/graph/store.ts b/backend/src/graph/store.ts index 3348202..291e6ea 100644 --- a/backend/src/graph/store.ts +++ b/backend/src/graph/store.ts @@ -1,6 +1,7 @@ import type { PodGraph, GraphNodeDoc, GraphEdgeDoc } from '@podman/shared'; import { getDb } from '../memory/db.js'; import { createDemoPodGraph } from './demo.js'; +import { materializePodGraph } from './live.js'; interface TeamModelDoc { podId: string; @@ -14,6 +15,14 @@ interface TeamModelDoc { * unreachable — so the demo path never depends on a populated DB. */ export async function loadPodGraph(podId: string): Promise { + // 1. Live: materialize from the real collections (observations/collisions/…). + try { + const live = await materializePodGraph(podId); + if (live) return live; + } catch (err) { + console.warn(`[graph] live materialize failed, falling back: ${(err as Error).message}`); + } + // 2. Seeded snapshot embedded in team_model. try { const db = await getDb(); const doc = await db.collection('team_model').findOne({ podId }); @@ -21,6 +30,7 @@ export async function loadPodGraph(podId: string): Promise { } catch (err) { console.warn(`[graph] loadPodGraph fell back to demo: ${(err as Error).message}`); } + // 3. Demo (stage safety — never an empty canvas). return createDemoPodGraph(podId); } diff --git a/docs/graph.md b/docs/graph.md index c0f7956..e0ad3e1 100644 --- a/docs/graph.md +++ b/docs/graph.md @@ -76,13 +76,30 @@ Additive routes in `backend/src/server.ts` (shared file — additive only). - `shared/src/graph.ts` — `PodGraph`, `PodGraphNode/Edge/Metric`, `GraphNodeDoc`, `GraphEdgeDoc` - `backend/src/graph/demo.ts` — `createDemoPodGraph(podId)` (grounded in the demo-pod crew) -- `backend/src/graph/store.ts` — `loadPodGraph`, `seedGraph`, `reachFrom` (`$graphLookup`) +- `backend/src/graph/live.ts` — **`materializePodGraph(podId)`**: builds the graph from the real + collections (pods, engineer_states, observations, collisions, interventions, outcomes) +- `backend/src/graph/store.ts` — `loadPodGraph` (live → seeded → demo), `seedGraph`, `reachFrom` (`$graphLookup`) - `backend/src/graph/seed.ts` — `pnpm graph:seed` (writes demo into `team_model` + graph collections) - `frontend/src/lib/graph.ts` — `fetchPodGraph(podId)` -- `frontend/src/components/GraphView.tsx` — dark-Bauhaus SVG graph (toggle from `App.tsx`) +- `frontend/src/components/GraphView.tsx` — shadcn-themed SVG graph (theme-aware; toggle from `App.tsx`) -## Demo-first plan +## Live data → graph mapping -1. Serve `createDemoPodGraph()` from the route (demo-stable, no DB dependency on the demo path). -2. `pnpm graph:seed` writes the same graph into Mongo so `$graphLookup` is real, not a mock. -3. Swap `loadPodGraph` to read live `team_model.graph` once the ingest pipeline populates it. +`materializePodGraph` reads the 5 real collections per pod and emits a `PodGraph`: + +| Collection | Produces | +| ----------------- | -------------------------------------------------------------------------- | +| `pods.members` | baseline **engineer** nodes | +| `engineer_states` | engineer `risk` if unpushed; **file** nodes (git paths parsed); `editing` | +| `observations` | engineer `active`; **file** from `currentFile`; `editing` (strength=conf.) | +| `collisions` | **collision** nodes; `collides` (eng→col) + `touches` (file→col) | +| `interventions` | **intervention** nodes; `warns` (col→intervention) | +| `outcomes` | `learned_from` (intervention→owner) on accepted; flips nodes to `learned` | + +Metrics (learned owners / open risk paths / accept rate) are live counts. + +## Fallback order (`loadPodGraph`) + +1. **Live** — `materializePodGraph` from the real collections (returns `null` if only bare roster). +2. **Seeded** — `team_model.graph` (from `pnpm graph:seed`). +3. **Demo** — `createDemoPodGraph()` (stage safety; never an empty canvas). diff --git a/frontend/src/components/GraphView.tsx b/frontend/src/components/GraphView.tsx index ad2b58b..7c13635 100644 --- a/frontend/src/components/GraphView.tsx +++ b/frontend/src/components/GraphView.tsx @@ -1,24 +1,36 @@ import { useEffect, useMemo, useState, type CSSProperties } from 'react'; import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared'; import { fetchPodGraph } from '../lib/graph.js'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; type Mode = 'risk' | 'learn' | 'all'; +// Fixed, light-readable hues for the node/edge encoding (kept stable across +// light/dark so kinds stay distinguishable; the chrome uses shadcn tokens). +const BLUE = '#2563eb'; +const SLATE = '#475569'; +const SLATE_EDGE = '#94a3b8'; +const SLATE_FAINT = '#cbd5e1'; +const AMBER = '#d97706'; +const RED = '#dc2626'; +const VIOLET = '#7c3aed'; + const KIND_COLOR: Record = { - engineer: '#3B5BFF', - file: '#ECE7DA', - feature: '#F6C445', - collision: '#E2403A', - intervention: '#8b6cff', + engineer: BLUE, + file: SLATE, + feature: AMBER, + collision: RED, + intervention: VIOLET, }; const EDGE: Record = { - owns: { c: '#3B5BFF', w: 2.6 }, - editing: { c: '#ECE7DA', w: 2 }, - touches: { c: '#5d5d66', w: 1.6 }, - collides: { c: '#E2403A', w: 3.2 }, - warns: { c: '#F6C445', w: 3.2 }, - learned_from: { c: '#8b6cff', w: 2.4, dash: true }, + owns: { c: BLUE, w: 2.6 }, + editing: { c: SLATE_EDGE, w: 2 }, + touches: { c: SLATE_FAINT, w: 1.6 }, + collides: { c: RED, w: 3.2 }, + warns: { c: AMBER, w: 3.2 }, + learned_from: { c: VIOLET, w: 2.4, dash: true }, }; function NodeShape({ node }: { node: PodGraphNode }) { @@ -26,7 +38,7 @@ function NodeShape({ node }: { node: PodGraphNode }) { const { x, y } = node; switch (node.kind) { case 'engineer': - return ; + return ; case 'file': return ( = [ - { label: 'engineer', swatch: { background: '#3B5BFF' } }, - { label: 'file', swatch: { border: '2px solid #ECE7DA' } }, - { label: 'feature', swatch: { background: '#F6C445', borderRadius: '50%' } }, - { - label: 'collision', - swatch: { background: '#E2403A', clipPath: 'polygon(50% 0,100% 100%,0 100%)' }, - }, - { label: 'intervention', swatch: { background: '#8b6cff', transform: 'rotate(45deg)' } }, + { label: 'engineer', swatch: { background: BLUE } }, + { label: 'file', swatch: { border: `2px solid ${SLATE}` } }, + { label: 'feature', swatch: { background: AMBER, borderRadius: '50%' } }, + { label: 'collision', swatch: { background: RED, clipPath: 'polygon(50% 0,100% 100%,0 100%)' } }, + { label: 'intervention', swatch: { background: VIOLET, transform: 'rotate(45deg)' } }, ]; +function statusColor(status: string): string { + if (status === 'risk') return RED; + if (status === 'learned') return VIOLET; + return 'var(--foreground)'; +} + export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) { const [graph, setGraph] = useState(null); const [error, setError] = useState(null); @@ -128,201 +144,174 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo setSelected(null); } + const toggleVariant = (m: Mode) => (mode === m && !selected ? 'default' : 'outline'); + return ( -
- +
+
+ -
-
-
Team memory
-
What PodMan learned · {podId}
-
- -
+
+
+
+

Team memory

+

What PodMan learned · {podId}

+
+ +
-
- - - -
+
+ + + +
- {error && ( -

Graph error: {error}

- )} - {!graph && !error && ( -

Loading graph…

- )} + {error &&

Graph error: {error}

} + {!graph && !error && ( +

Loading graph…

+ )} - {graph && ( - <> -
-
-
Workflow metrics
- {graph.metrics.map((m) => ( -
-
{m.value}
-
{m.label}
-
{m.detail}
+ {graph && ( + <> +
+
+

+ Workflow metrics +

+ {graph.metrics.map((m) => ( +
+

{m.value}

+

+ {m.label} +

+

{m.detail}

+
+ ))}
- ))} -
-
- - {graph.edges.map((e) => { - const a = nodeById.get(e.source); - const b = nodeById.get(e.target); - if (!a || !b) return null; - const s = EDGE[e.kind]; - return ( - - ); - })} - {graph.nodes.map((n) => ( - setSelected((cur) => (cur === n.id ? null : n.id))} - onKeyDown={(ev) => { - if (ev.key === 'Enter' || ev.key === ' ') { - ev.preventDefault(); - setSelected((cur) => (cur === n.id ? null : n.id)); - } - }} +
+ - - - {n.label.toUpperCase()} - - + {graph.edges.map((e) => { + const a = nodeById.get(e.source); + const b = nodeById.get(e.target); + if (!a || !b) return null; + const s = EDGE[e.kind]; + return ( + + ); + })} + {graph.nodes.map((n) => ( + setSelected((cur) => (cur === n.id ? null : n.id))} + onKeyDown={(ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + setSelected((cur) => (cur === n.id ? null : n.id)); + } + }} + > + + + {n.label} + + + ))} + +
+ +
+ {sel ? ( + <> +

+ {sel.kind} +

+

{sel.label}

+
+ Status + + {sel.status} + +
+
+ Relationships + {relCount} +
+

+ {sel.summary} +

+ + ) : ( + <> +

+ Continual learning +

+

It learned

+

+ The violet{' '} + + learned_from + {' '} + edges are ownership PodMan retained from accepted interventions — the graph + gets sharper every session. Click any node to trace its relationships. +

+ + )} +
+
+ +
+ {LEGEND.map((l) => ( + + + {l.label} + ))} - -
- -
- {sel ? ( - <> -
{sel.kind}
-
{sel.label}
-
- Status - - {sel.status} - -
-
- Relationships - {relCount} -
-
{sel.summary}
- - ) : ( - <> -
Continual learning
-
It learned
-
- Violet learned_from edges are ownership - PodMan retained from accepted interventions — the graph gets sharper every - session. Click any node to trace its relationships. -
- - )} -
-
- -
- {LEGEND.map((l) => ( - - - {l.label} - - ))} - - - collides - - - - learned_from - -
- - )} + + + collides + + + + learned_from + +
+ + )} +
+
); } From 412d3f7df6a6524d93165160674a66624ed30f33 Mon Sep 17 00:00:00 2001 From: sb-iam <59984144+sb-iam@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:03:57 -0700 Subject: [PATCH 2/3] tune(graph): de-noise the live materializer Verified materializePodGraph against real demo-pod data (joins all connect: 21/21 interventions->collisions, learned_from produced). Tuning: - engineer nodes are case-insensitive (merges Shakthi/shakthi) - git (engineer_states) now only CONFIRMS editing on files vision/collisions already surfaced, instead of adding the whole repo diff (killed a 29-file node explosion from a watcher running against the full podman repo) Cut demo-pod from 110 -> 77 nodes; git file-edges 39 -> 6. Co-Authored-By: Claude Opus 4.8 --- backend/src/graph/live.ts | 41 ++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/backend/src/graph/live.ts b/backend/src/graph/live.ts index 377b1b3..e4cf481 100644 --- a/backend/src/graph/live.ts +++ b/backend/src/graph/live.ts @@ -60,7 +60,7 @@ function upsertNode( key: string, patch: Partial>, ): string { - const id = nodeKey(kind, key); + const id = nodeKey(kind, kind === 'engineer' ? key.toLowerCase() : key); const cur = b.nodes.get(id); if (!cur) { b.nodes.set(id, { @@ -158,24 +158,7 @@ export async function materializePodGraph(podId: string): Promise 0 ? 'risk' : 'active', - summary: files.length - ? `${files.length} changed file(s) on ${git.branch ?? 'detached'}` - : `on ${git.branch ?? 'detached'}`, - weight: 0.7, - }); - for (const file of files) { - const f = upsertNode(b, 'file', file, { label: file, status: 'risk' }); - upsertEdge(b, eng, f, 'editing', 'edits', 0.6); - } - } - - // 3. Vision (observations): who is active and on which file, with confidence. + // 2. Vision (observations): who is active and on which file, with confidence. for (const o of observations) { if (!o.engineerId) continue; const recent = o.observedAt && now - new Date(o.observedAt).getTime() < ACTIVE_WINDOW_MS; @@ -190,7 +173,7 @@ export async function materializePodGraph(podId: string): Promise(); for (const col of collisionDocs) { collisionById.set(col.id, col); @@ -211,6 +194,24 @@ export async function materializePodGraph(podId: string): Promise 0 ? 'risk' : 'active', + summary: files.length + ? `${files.length} changed file(s) on ${git.branch ?? 'detached'}` + : `on ${git.branch ?? 'detached'}`, + weight: 0.7, + }); + for (const file of files) { + const fid = nodeKey('file', file); + if (b.nodes.has(fid)) upsertEdge(b, eng, fid, 'editing', 'edits', 0.6); + } + } + // 5. Interventions: what PodMan offered for each collision. const interventionById = new Map(); for (const iv of interventionDocs) { From 2006096cdadffe978a05d7dc6c4d011d096210bf Mon Sep 17 00:00:00 2001 From: sb-iam <59984144+sb-iam@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:35:14 -0700 Subject: [PATCH 3/3] feat(graph): demo-ready materializer + per-pod Team memory action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Materializer (live.ts) — cut demo-pod from 110 -> 22 nodes: - cap to 8 recent collisions; collapse repeats by memorySignature - collapse interventions to one (most recent) per collision - filter junk 'files' (URLs, env vars, browser/app names, scratch/test) to real source paths only - prune test-artifact engineers (a/b/verify/codex-check/-testrepo) + any node orphaned by that - collisions referenced by accepted outcomes bypass the cap so the learned_from money path never drops; risk-paths metric counts distinct signatures UI: per-pod 'Team memory' action on each PodCard's menu (replaces the header button that always opened pods[0]). Verified against live demo-pod data (joins connect); typechecks clean. Co-Authored-By: Claude Opus 4.8 --- backend/src/graph/live.ts | 118 ++++++++++++++++++++++++---- frontend/src/App.tsx | 5 +- frontend/src/components/PodCard.tsx | 15 +++- 3 files changed, 117 insertions(+), 21 deletions(-) diff --git a/backend/src/graph/live.ts b/backend/src/graph/live.ts index e4cf481..0eadcd3 100644 --- a/backend/src/graph/live.ts +++ b/backend/src/graph/live.ts @@ -35,9 +35,24 @@ export function normalizeFile(f: string): string { return f .trim() .replace(/^["']|["']$/g, '') + .replace(/^[ACDMRTU?!]{1,2}\s+/, '') .replace(/^\.\//, ''); } +const MAX_COLLISIONS = 8; + +/** Reject "file" values that aren't real source paths — vision/git noise such as + * URLs, env vars, browser/app names, and scratch/test artifacts. */ +const FILE_NOISE = + /(:\/\/|^[#~]|\s|\.env\b|\btett\b|test-change|demo-scratch|podman-test|scratch|sslip)/i; +export function isFilePath(f: string): boolean { + if (!f || FILE_NOISE.test(f)) return false; + return /\.[a-z0-9]{1,6}$/i.test(f); // must end in a real file extension +} + +/** Engineer names that are test/verification artifacts, not real teammates. */ +const ENGINEER_NOISE = /(^verify\b|^.$|testrepo|-?check\b|\d{4,})/i; + const STATUS_RANK: Record = { stable: 0, active: 1, @@ -166,32 +181,67 @@ export async function materializePodGraph(podId: string): Promise(); + for (const out of outcomeDocs) { + if (!out.accepted || !out.wasRealCollision) continue; + if (out.collisionId) priorityCol.add(out.collisionId); + const iv = interventionDocs.find((i) => i.id === out.interventionId); + if (iv?.collisionId) priorityCol.add(iv.collisionId); + } + + // 3. Collisions: collapse repeats by signature, keep the most recent, cap to + // MAX_COLLISIONS, skip junk-file collisions. `collisionById` keeps every doc + // (for the outcome join); `colNodeFor` maps each collisionId to its surviving + // collision node (or null when collapsed / capped / filtered out). const collisionById = new Map(); + const colNodeFor = new Map(); + const sigToNode = new Map(); + let distinctCollisions = 0; for (const col of collisionDocs) { collisionById.set(col.id, col); + const file = normalizeFile(col.file); + const sig = + (col as { memorySignature?: string }).memorySignature ?? `${file}#${col.symbol ?? ''}`; + const existing = sigToNode.get(sig); + if (existing) { + colNodeFor.set(col.id, existing); + continue; + } + if (!isFilePath(file)) { + colNodeFor.set(col.id, null); + continue; + } + const isPriority = priorityCol.has(col.id); + if (!isPriority && distinctCollisions >= MAX_COLLISIONS) { + colNodeFor.set(col.id, null); + continue; + } const cNode = upsertNode(b, 'collision', col.id, { - label: col.symbol ? `${col.file}#${col.symbol}` : col.file, + label: col.symbol ? `${file}#${col.symbol}` : file, status: 'risk', weight: SEVERITY_WEIGHT[col.severity] ?? 0.7, - summary: `${col.engineers.join(' + ')} on ${col.file}${ + summary: `${col.engineers.join(' + ')} on ${file}${ (col as { memorySignature?: string }).memorySignature ? ' · seen before' : '' }`, }); - const file = normalizeFile(col.file); const fNode = upsertNode(b, 'file', file, { label: file, status: 'risk' }); upsertEdge(b, fNode, cNode, 'touches', 'hot', 0.6); for (const name of col.engineers) { const eng = upsertNode(b, 'engineer', name, { label: name }); upsertEdge(b, eng, cNode, 'collides', 'in', SEVERITY_WEIGHT[col.severity] ?? 0.7); } + sigToNode.set(sig, cNode); + colNodeFor.set(col.id, cNode); + if (!isPriority) distinctCollisions++; } // 4. Git truth (engineer_states): mark unpushed work and confirm editing on @@ -212,10 +262,16 @@ export async function materializePodGraph(podId: string): Promise(); - for (const iv of interventionDocs) { + const ivNodeForCol = new Map(); + const sortedIvs = [...interventionDocs].sort((a, b) => + String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')), + ); + for (const iv of sortedIvs) { interventionById.set(iv.id, iv); + const colNode = colNodeFor.get(iv.collisionId); + if (!colNode || ivNodeForCol.has(colNode)) continue; const ivNode = upsertNode(b, 'intervention', iv.id, { label: iv.suggestedAction?.kind === 'open_sync_pr' @@ -225,9 +281,8 @@ export async function materializePodGraph(podId: string): Promise learned_from edges + owns. @@ -237,16 +292,41 @@ export async function materializePodGraph(podId: string): Promise { + b.nodes.delete(id); + for (const [eid, e] of [...b.edges]) + if (e.source === id || e.target === id) b.edges.delete(eid); + }; + for (const [id, n] of [...b.nodes]) { + if (n.kind === 'engineer' && ENGINEER_NOISE.test(n.label)) dropNode(id); + } + // Collisions with no remaining engineer = test/orphan -> drop. + for (const [id, n] of [...b.nodes]) { + if (n.kind !== 'collision') continue; + if (![...b.edges.values()].some((e) => e.kind === 'collides' && e.target === id)) dropNode(id); + } + // Files / interventions left with no edges -> drop. + for (const [id, n] of [...b.nodes]) { + if (n.kind === 'file' || n.kind === 'intervention') { + if (![...b.edges.values()].some((e) => e.source === id || e.target === id)) + b.nodes.delete(id); } } @@ -259,7 +339,13 @@ export async function materializePodGraph(podId: string): Promise o.accepted && o.wasRealCollision).length; const totalOutcomes = outcomeDocs.length; - const riskPaths = collisionDocs.filter((col) => new Set(col.engineers).size >= 2).length; + const riskPaths = new Set( + collisionDocs.map( + (col) => + (col as { memorySignature?: string }).memorySignature ?? + `${normalizeFile(col.file)}#${col.symbol ?? ''}`, + ), + ).size; const metrics: PodGraphMetric[] = [ { label: 'Learned owners', diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3ee58fb..a632d13 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -268,10 +268,6 @@ export default function App() { Privacy-limited -
diff --git a/frontend/src/components/PodCard.tsx b/frontend/src/components/PodCard.tsx index 330b62a..a609691 100644 --- a/frontend/src/components/PodCard.tsx +++ b/frontend/src/components/PodCard.tsx @@ -1,5 +1,12 @@ import { useState } from 'react'; -import { MoreHorizontalIcon, PlusIcon, Trash2Icon, UserRoundIcon, VideoIcon } from 'lucide-react'; +import { + BrainCircuitIcon, + MoreHorizontalIcon, + PlusIcon, + Trash2Icon, + UserRoundIcon, + VideoIcon, +} from 'lucide-react'; import type { Pod, PodInput } from '@podman/shared'; import { Avatar, AvatarBadge, AvatarFallback, AvatarGroup } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; @@ -42,6 +49,7 @@ export function PodCard({ onRemoveMember: _onRemoveMember, onUpdate, onDelete, + onOpenGraph, }: { pod: Pod; busy: boolean; @@ -52,6 +60,7 @@ export function PodCard({ onRemoveMember: (id: string, name: string) => void; onUpdate: (id: string, patch: PodInput) => void; onDelete: (id: string) => void; + onOpenGraph: (id: string) => void; }) { const [newMember, setNewMember] = useState(''); const [editing, setEditing] = useState(false); @@ -100,6 +109,10 @@ export function PodCard({ + onOpenGraph(pod.id)}> + + Team memory + setEditing(true)}>Edit pod