85ab8da598
- 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 <noreply@anthropic.com>
115 lines
3.9 KiB
TypeScript
115 lines
3.9 KiB
TypeScript
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;
|
|
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> {
|
|
// 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<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}`);
|
|
}
|
|
// 3. Demo (stage safety — never an empty canvas).
|
|
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<GraphEdgeDoc & { reaches: GraphEdgeDoc[] }>([
|
|
{ $match: { podId, source: startNodeId } },
|
|
{
|
|
$graphLookup: {
|
|
from: 'graph_edges',
|
|
startWith: '$target',
|
|
connectFromField: 'target',
|
|
connectToField: 'source',
|
|
as: 'reaches',
|
|
restrictSearchWithMatch: { podId },
|
|
maxDepth: 6,
|
|
},
|
|
},
|
|
])
|
|
.toArray();
|
|
|
|
// Include the direct outbound edges (the $match rows) plus everything reachable
|
|
// from them, de-duped by edge id. Without the direct rows, edges straight off
|
|
// the start node go missing unless a cycle happens to re-discover them.
|
|
const seen = new Set<string>();
|
|
const reaches: GraphEdgeDoc[] = [];
|
|
for (const row of rows) {
|
|
const { reaches: recursive, ...direct } = row;
|
|
for (const edge of [direct as GraphEdgeDoc, ...(recursive ?? [])]) {
|
|
if (edge?.id && !seen.has(edge.id)) {
|
|
seen.add(edge.id);
|
|
reaches.push(edge);
|
|
}
|
|
}
|
|
}
|
|
return { start: startNodeId, reaches };
|
|
}
|