feat(graph): continual-learning graph data model + dark-Bauhaus viz
Adds the team_model graph layer (the "it learned" view) and its visualization,
per docs/graph.md. Demo-backed first; built on origin/main conventions.
- shared: PodGraph / node / edge / metric types + GraphNodeDoc / GraphEdgeDoc
- backend/src/graph: demo graph data; Mongo store (loadPodGraph w/ demo
fallback, seedGraph, reachFrom via $graphLookup); graph:seed script
- backend/src/server.ts: additive GET /api/pods/:id/graph + /graph/reach/:node
- frontend: dark-Bauhaus GraphView + fetch helper, reachable from a
"Team memory" toggle in App.tsx
- docs/graph.md: spec (data model, $graphLookup, API, demo-first plan)
Demo-backed: the route serves a grounded demo graph when team_model has no
graph yet; graph:seed writes team_model + graph_nodes/graph_edges so the
$graphLookup traversal is real. Swap loadPodGraph to live team_model later.
Known follow-ups before merge: branch is based on 65a0791; origin/main is now
at 1294b84, so this needs a rebase + App.tsx conflict resolution (teammate
rewrote App.tsx with live room view / one-click join).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"dev:server": "tsx watch src/server.ts",
|
||||
"dev:agent": "tsx watch src/agent.ts",
|
||||
"graph:seed": "tsx src/graph/seed.ts",
|
||||
"start": "node dist/server.js",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { PodGraph } from '@podman/shared';
|
||||
|
||||
/**
|
||||
* Demo continual-learning graph, grounded in the demo-pod crew. Used as the
|
||||
* served graph until the ingest pipeline populates `team_model.graph`, and as
|
||||
* the seed for the `graph_nodes` / `graph_edges` collections. The hero path —
|
||||
* Karti + Yahya editing auth.ts -> collision -> sync PR -> *learned: Karti owns
|
||||
* auth* — is the continual-learning story the demo lights up.
|
||||
*/
|
||||
export function createDemoPodGraph(podId: string): PodGraph {
|
||||
return {
|
||||
podId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
metrics: [
|
||||
{
|
||||
label: 'Learned owners',
|
||||
value: '5',
|
||||
detail: 'Ownership edges retained from accepted interventions.',
|
||||
},
|
||||
{
|
||||
label: 'Open risk paths',
|
||||
value: '2',
|
||||
detail: 'auth.ts and the memory API have converging editors.',
|
||||
},
|
||||
{
|
||||
label: 'Accept rate',
|
||||
value: '86%',
|
||||
detail: 'Interventions accepted this session (+14%).',
|
||||
},
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
id: 'engineer:shakthi',
|
||||
kind: 'engineer',
|
||||
label: 'Shakthi',
|
||||
summary: 'Owns the team-memory graph + visualization.',
|
||||
weight: 0.8,
|
||||
status: 'active',
|
||||
x: 78,
|
||||
y: 70,
|
||||
},
|
||||
{
|
||||
id: 'engineer:karti',
|
||||
kind: 'engineer',
|
||||
label: 'Karti',
|
||||
summary: 'Learned owner of auth; backend + DB wiring.',
|
||||
weight: 0.9,
|
||||
status: 'learned',
|
||||
x: 78,
|
||||
y: 188,
|
||||
},
|
||||
{
|
||||
id: 'engineer:yahya',
|
||||
kind: 'engineer',
|
||||
label: 'Yahya',
|
||||
summary: 'Editing auth.ts with unpushed changes.',
|
||||
weight: 0.82,
|
||||
status: 'risk',
|
||||
x: 78,
|
||||
y: 300,
|
||||
},
|
||||
{
|
||||
id: 'engineer:ramis',
|
||||
kind: 'engineer',
|
||||
label: 'Ramis',
|
||||
summary: 'On the memory store + agent pipeline.',
|
||||
weight: 0.66,
|
||||
status: 'stable',
|
||||
x: 78,
|
||||
y: 404,
|
||||
},
|
||||
{
|
||||
id: 'engineer:zander',
|
||||
kind: 'engineer',
|
||||
label: 'Zander',
|
||||
summary: 'Owns the realtime pod + capture.',
|
||||
weight: 0.6,
|
||||
status: 'stable',
|
||||
x: 214,
|
||||
y: 440,
|
||||
},
|
||||
{
|
||||
id: 'file:auth.ts',
|
||||
kind: 'file',
|
||||
label: 'auth.ts',
|
||||
summary: 'Hot file — two live editors before push.',
|
||||
weight: 0.92,
|
||||
status: 'risk',
|
||||
x: 300,
|
||||
y: 150,
|
||||
},
|
||||
{
|
||||
id: 'file:memory-store',
|
||||
kind: 'file',
|
||||
label: 'memory/store',
|
||||
summary: 'Shared memory API surface.',
|
||||
weight: 0.72,
|
||||
status: 'active',
|
||||
x: 250,
|
||||
y: 330,
|
||||
},
|
||||
{
|
||||
id: 'feature:continual-memory',
|
||||
kind: 'feature',
|
||||
label: 'continual memory',
|
||||
summary: 'Atlas team_model + Voyage recall.',
|
||||
weight: 1,
|
||||
status: 'active',
|
||||
x: 442,
|
||||
y: 300,
|
||||
},
|
||||
{
|
||||
id: 'feature:realtime-pod',
|
||||
kind: 'feature',
|
||||
label: 'realtime pod',
|
||||
summary: 'LiveKit screen stream in.',
|
||||
weight: 0.8,
|
||||
status: 'active',
|
||||
x: 360,
|
||||
y: 418,
|
||||
},
|
||||
{
|
||||
id: 'collision:auth',
|
||||
kind: 'collision',
|
||||
label: 'auth.ts overlap',
|
||||
summary: 'Karti + Yahya editing auth.ts, unpushed — git cannot see it.',
|
||||
weight: 0.95,
|
||||
status: 'risk',
|
||||
x: 440,
|
||||
y: 118,
|
||||
},
|
||||
{
|
||||
id: 'intervention:sync-pr',
|
||||
kind: 'intervention',
|
||||
label: 'sync PR',
|
||||
summary: 'PodMan offered to open a draft sync PR.',
|
||||
weight: 0.7,
|
||||
status: 'learned',
|
||||
x: 622,
|
||||
y: 118,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'e1',
|
||||
source: 'engineer:karti',
|
||||
target: 'file:auth.ts',
|
||||
kind: 'owns',
|
||||
label: 'owns',
|
||||
strength: 0.9,
|
||||
},
|
||||
{
|
||||
id: 'e2',
|
||||
source: 'engineer:yahya',
|
||||
target: 'file:auth.ts',
|
||||
kind: 'editing',
|
||||
label: 'edits',
|
||||
strength: 0.8,
|
||||
},
|
||||
{
|
||||
id: 'e3',
|
||||
source: 'engineer:karti',
|
||||
target: 'collision:auth',
|
||||
kind: 'collides',
|
||||
label: 'in',
|
||||
strength: 0.85,
|
||||
},
|
||||
{
|
||||
id: 'e4',
|
||||
source: 'engineer:yahya',
|
||||
target: 'collision:auth',
|
||||
kind: 'collides',
|
||||
label: 'in',
|
||||
strength: 0.85,
|
||||
},
|
||||
{
|
||||
id: 'e5',
|
||||
source: 'file:auth.ts',
|
||||
target: 'collision:auth',
|
||||
kind: 'touches',
|
||||
label: 'hot',
|
||||
strength: 0.7,
|
||||
},
|
||||
{
|
||||
id: 'e6',
|
||||
source: 'collision:auth',
|
||||
target: 'intervention:sync-pr',
|
||||
kind: 'warns',
|
||||
label: 'nudges',
|
||||
strength: 0.9,
|
||||
},
|
||||
{
|
||||
id: 'e7',
|
||||
source: 'intervention:sync-pr',
|
||||
target: 'engineer:karti',
|
||||
kind: 'learned_from',
|
||||
label: 'learned: owns auth',
|
||||
strength: 0.6,
|
||||
},
|
||||
{
|
||||
id: 'e8',
|
||||
source: 'engineer:ramis',
|
||||
target: 'file:memory-store',
|
||||
kind: 'editing',
|
||||
label: 'edits',
|
||||
strength: 0.7,
|
||||
},
|
||||
{
|
||||
id: 'e9',
|
||||
source: 'engineer:karti',
|
||||
target: 'file:memory-store',
|
||||
kind: 'editing',
|
||||
label: 'edits',
|
||||
strength: 0.5,
|
||||
},
|
||||
{
|
||||
id: 'e10',
|
||||
source: 'file:memory-store',
|
||||
target: 'feature:continual-memory',
|
||||
kind: 'touches',
|
||||
label: 'persists',
|
||||
strength: 0.8,
|
||||
},
|
||||
{
|
||||
id: 'e11',
|
||||
source: 'engineer:shakthi',
|
||||
target: 'feature:continual-memory',
|
||||
kind: 'owns',
|
||||
label: 'models',
|
||||
strength: 0.85,
|
||||
},
|
||||
{
|
||||
id: 'e12',
|
||||
source: 'engineer:zander',
|
||||
target: 'feature:realtime-pod',
|
||||
kind: 'owns',
|
||||
label: 'owns',
|
||||
strength: 0.75,
|
||||
},
|
||||
{
|
||||
id: 'e13',
|
||||
source: 'feature:realtime-pod',
|
||||
target: 'file:memory-store',
|
||||
kind: 'touches',
|
||||
label: 'feeds',
|
||||
strength: 0.55,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { seedGraph } from './store.js';
|
||||
|
||||
/**
|
||||
* Seed a pod's continual-learning graph into Mongo (team_model + graph_nodes +
|
||||
* graph_edges). Usage: `pnpm graph:seed [podId]` (defaults to demo-pod).
|
||||
*/
|
||||
const podId = process.argv[2] ?? 'demo-pod';
|
||||
|
||||
seedGraph(podId)
|
||||
.then((graph) => {
|
||||
console.log(
|
||||
`[graph] seeded ${graph.nodes.length} nodes / ${graph.edges.length} edges for pod "${podId}"`,
|
||||
);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error(`[graph] seed failed: ${(err as Error).message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { PodGraph, GraphNodeDoc, GraphEdgeDoc } from '@podman/shared';
|
||||
import { getDb } from '../memory/db.js';
|
||||
import { createDemoPodGraph } from './demo.js';
|
||||
|
||||
interface TeamModelDoc {
|
||||
podId: string;
|
||||
graph?: PodGraph;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a pod's continual-learning graph. Reads the embedded `team_model.graph`;
|
||||
* falls back to a grounded demo graph when none exists yet or Mongo is
|
||||
* unreachable — so the demo path never depends on a populated DB.
|
||||
*/
|
||||
export async function loadPodGraph(podId: string): Promise<PodGraph> {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const doc = await db.collection<TeamModelDoc>('team_model').findOne({ podId });
|
||||
if (doc?.graph) return doc.graph;
|
||||
} catch (err) {
|
||||
console.warn(`[graph] loadPodGraph fell back to demo: ${(err as Error).message}`);
|
||||
}
|
||||
return createDemoPodGraph(podId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a pod's graph into Mongo: embed it in `team_model` and mirror nodes/edges
|
||||
* into `graph_nodes` / `graph_edges` so `$graphLookup` traversal is real, not a
|
||||
* mock. Idempotent — safe to run repeatedly.
|
||||
*/
|
||||
export async function seedGraph(podId: string): Promise<PodGraph> {
|
||||
const db = await getDb();
|
||||
const graph = createDemoPodGraph(podId);
|
||||
const nodes = db.collection<GraphNodeDoc>('graph_nodes');
|
||||
const edges = db.collection<GraphEdgeDoc>('graph_edges');
|
||||
|
||||
await Promise.all([
|
||||
nodes.createIndex({ podId: 1, id: 1 }, { unique: true }),
|
||||
edges.createIndex({ podId: 1, source: 1 }),
|
||||
db.collection('team_model').createIndex({ podId: 1 }, { unique: true }),
|
||||
]);
|
||||
|
||||
await db
|
||||
.collection<TeamModelDoc>('team_model')
|
||||
.updateOne(
|
||||
{ podId },
|
||||
{ $set: { podId, graph, updatedAt: new Date().toISOString() } },
|
||||
{ upsert: true },
|
||||
);
|
||||
|
||||
await Promise.all([nodes.deleteMany({ podId }), edges.deleteMany({ podId })]);
|
||||
if (graph.nodes.length) await nodes.insertMany(graph.nodes.map((n) => ({ ...n, podId })));
|
||||
if (graph.edges.length) await edges.insertMany(graph.edges.map((e) => ({ ...e, podId })));
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
export interface ReachResult {
|
||||
start: string;
|
||||
reaches: GraphEdgeDoc[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the directed edge chain from a node with MongoDB `$graphLookup` — answers
|
||||
* "what does this node's work reach?" (engineer -> file -> collision ->
|
||||
* intervention). This is the graph-database traversal that powers the risk path.
|
||||
*/
|
||||
export async function reachFrom(podId: string, startNodeId: string): Promise<ReachResult> {
|
||||
const db = await getDb();
|
||||
const rows = await db
|
||||
.collection<GraphEdgeDoc>('graph_edges')
|
||||
.aggregate<{ reaches: GraphEdgeDoc[] }>([
|
||||
{ $match: { podId, source: startNodeId } },
|
||||
{
|
||||
$graphLookup: {
|
||||
from: 'graph_edges',
|
||||
startWith: '$target',
|
||||
connectFromField: 'target',
|
||||
connectToField: 'source',
|
||||
as: 'reaches',
|
||||
restrictSearchWithMatch: { podId },
|
||||
},
|
||||
},
|
||||
])
|
||||
.toArray();
|
||||
return { start: startNodeId, reaches: rows.flatMap((r) => r.reaches) };
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
seedDefaultPods,
|
||||
} from './pods/store.js';
|
||||
import { getPresence } from './livekit/rooms.js';
|
||||
import { loadPodGraph, reachFrom } from './graph/store.js';
|
||||
import type { InterventionOutcome } from '@podman/shared';
|
||||
|
||||
const app = express();
|
||||
@@ -137,6 +138,23 @@ app.delete('/api/pods/:id/members/:name', async (req, res) => {
|
||||
res.json(pod);
|
||||
});
|
||||
|
||||
// --- Continual-learning graph (team_model view) ---
|
||||
app.get('/api/pods/:id/graph', async (req, res) => {
|
||||
try {
|
||||
res.json(await loadPodGraph(req.params.id));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/pods/:id/graph/reach/:node', async (req, res) => {
|
||||
try {
|
||||
res.json(await reachFrom(req.params.id, req.params.node));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
const http = createServer(app);
|
||||
|
||||
// ws relay: the agent pushes collision/intervention JSON here; PWAs subscribed by pod receive it.
|
||||
|
||||
Reference in New Issue
Block a user