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 + +
+ + )} +
+
); }