Merge pull request #2 from karti-ai/feat/graph-data-viz
feat(graph): continual-learning graph data model + dark-Bauhaus viz
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
"dev": "pnpm run dev:server",
|
"dev": "pnpm run dev:server",
|
||||||
"dev:server": "tsx watch src/server.ts",
|
"dev:server": "tsx watch src/server.ts",
|
||||||
"dev:agent": "tsx watch src/agent.ts",
|
"dev:agent": "tsx watch src/agent.ts",
|
||||||
|
"graph:seed": "tsx src/graph/seed.ts",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"start:server": "node dist/server.js",
|
"start:server": "node dist/server.js",
|
||||||
"start:agent": "node dist/agent.js",
|
"start:agent": "node dist/agent.js",
|
||||||
|
|||||||
@@ -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,104 @@
|
|||||||
|
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<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 };
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
seedDefaultPods,
|
seedDefaultPods,
|
||||||
} from './pods/store.js';
|
} from './pods/store.js';
|
||||||
import { getPresence, closeRoom } from './livekit/rooms.js';
|
import { getPresence, closeRoom } from './livekit/rooms.js';
|
||||||
|
import { loadPodGraph, reachFrom } from './graph/store.js';
|
||||||
import type { InterventionOutcome } from '@podman/shared';
|
import type { InterventionOutcome } from '@podman/shared';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -141,6 +142,23 @@ app.delete('/api/pods/:id/members/:name', async (req, res) => {
|
|||||||
res.json(pod);
|
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);
|
const http = createServer(app);
|
||||||
|
|
||||||
// ws relay: the agent pushes collision/intervention JSON here; PWAs subscribed by pod receive it.
|
// ws relay: the agent pushes collision/intervention JSON here; PWAs subscribed by pod receive it.
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Continual-Learning Graph Spec
|
||||||
|
|
||||||
|
> Owner: graph data + visualization. Status: demo-backed (live `team_model` reads land later).
|
||||||
|
> Satisfies the documentation-first gate for the `backend/src/graph/*` and
|
||||||
|
> `frontend/src/components/GraphView.tsx` files.
|
||||||
|
|
||||||
|
## What this is (and is NOT)
|
||||||
|
|
||||||
|
PodMan's **visible "it learned" surface**. The graph is a render of the per-pod
|
||||||
|
`team_model` — who **owns / edits** which files, where work **collides**, and what
|
||||||
|
PodMan **learned** from accepted interventions (the `learned_from` edges). It is the
|
||||||
|
continual-learning loop made legible in 10 seconds: _"PodMan now knows Karti owns auth."_
|
||||||
|
|
||||||
|
It is **not** a generic analytics dashboard (judges down-rank dashboard-as-product). It is a
|
||||||
|
**secondary view** behind the pods list — opened from a header toggle — that exists to make
|
||||||
|
the self-improving loop _visible_ during the demo. The landing surface stays the pods list.
|
||||||
|
|
||||||
|
## Data model — graph as a view of `team_model`
|
||||||
|
|
||||||
|
The graph lives in two places, both keyed by `podId`:
|
||||||
|
|
||||||
|
1. **Embedded (served to the viz):** the `team_model` document carries a `graph` field:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// team_model doc (one per pod, unique index { podId: 1 })
|
||||||
|
{ podId, graph: PodGraph, updatedAt }
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /api/pods/:podId/graph` returns `team_model.graph`, or a demo graph when none exists yet.
|
||||||
|
|
||||||
|
2. **Normalized (for traversal):** the same nodes/edges are mirrored into two collections so
|
||||||
|
the model can be walked with MongoDB `$graphLookup` (the graph-database pattern):
|
||||||
|
|
||||||
|
| Collection | Doc shape (`shared/src/graph.ts`) | Index |
|
||||||
|
| ------------- | ----------------------------------------- | ---------------------------- |
|
||||||
|
| `graph_nodes` | `GraphNodeDoc` = `PodGraphNode` + `podId` | `{ podId: 1, id: 1 }` unique |
|
||||||
|
| `graph_edges` | `GraphEdgeDoc` = `PodGraphEdge` + `podId` | `{ podId: 1, source: 1 }` |
|
||||||
|
|
||||||
|
Node kinds: `engineer · feature · file · collision · intervention`.
|
||||||
|
Edge kinds: `owns · editing · touches · collides · warns · learned_from`.
|
||||||
|
The `learned_from` edges are the continual-learning signal — derived from accepted
|
||||||
|
`interventions` / `outcomes` (ownership PodMan retains across sessions).
|
||||||
|
|
||||||
|
### Traversal (`$graphLookup`)
|
||||||
|
|
||||||
|
Walk the directed edge chain from any node (e.g. engineer → file → collision → intervention):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
db.collection('graph_edges').aggregate([
|
||||||
|
{ $match: { podId, source: startNodeId } },
|
||||||
|
{
|
||||||
|
$graphLookup: {
|
||||||
|
from: 'graph_edges',
|
||||||
|
startWith: '$target',
|
||||||
|
connectFromField: 'target',
|
||||||
|
connectToField: 'source',
|
||||||
|
as: 'reaches',
|
||||||
|
restrictSearchWithMatch: { podId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
This answers "what does this engineer's edit reach?" — the risk path PodMan lights up.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Method | Route | Returns |
|
||||||
|
| ------ | ---------------------------------- | --------------------------------------------- |
|
||||||
|
| `GET` | `/api/pods/:podId/graph` | `PodGraph` (live `team_model`, demo fallback) |
|
||||||
|
| `GET` | `/api/pods/:podId/graph/reach/:id` | `$graphLookup` reachability from node `:id` |
|
||||||
|
|
||||||
|
Additive routes in `backend/src/server.ts` (shared file — additive only).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `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/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`)
|
||||||
|
|
||||||
|
## Demo-first plan
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -17,6 +17,7 @@ import * as api from './lib/api.js';
|
|||||||
import { PodCard } from './components/PodCard.js';
|
import { PodCard } from './components/PodCard.js';
|
||||||
import { CreatePodForm } from './components/CreatePodForm.js';
|
import { CreatePodForm } from './components/CreatePodForm.js';
|
||||||
import { PodView } from './components/PodView.js';
|
import { PodView } from './components/PodView.js';
|
||||||
|
import { GraphView } from './components/GraphView.js';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -53,6 +54,7 @@ export default function App() {
|
|||||||
const [devMode, setDevMode] = useState(false);
|
const [devMode, setDevMode] = useState(false);
|
||||||
const [room, setRoom] = useState<Room | null>(null);
|
const [room, setRoom] = useState<Room | null>(null);
|
||||||
const [restoring, setRestoring] = useState(false);
|
const [restoring, setRestoring] = useState(false);
|
||||||
|
const [graphPodId, setGraphPodId] = useState<string | null>(null);
|
||||||
|
|
||||||
const joinedPod = joinedPodId ? (pods.find((p) => p.id === joinedPodId) ?? null) : null;
|
const joinedPod = joinedPodId ? (pods.find((p) => p.id === joinedPodId) ?? null) : null;
|
||||||
|
|
||||||
@@ -232,6 +234,10 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (graphPodId) {
|
||||||
|
return <GraphView podId={graphPodId} onClose={() => setGraphPodId(null)} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-[1440px] flex-col gap-6 px-4 py-4 sm:px-6 lg:px-8">
|
<div className="mx-auto flex min-h-screen w-full max-w-[1440px] flex-col gap-6 px-4 py-4 sm:px-6 lg:px-8">
|
||||||
@@ -262,6 +268,10 @@ export default function App() {
|
|||||||
<ShieldCheckIcon data-icon="inline-start" />
|
<ShieldCheckIcon data-icon="inline-start" />
|
||||||
Privacy-limited
|
Privacy-limited
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Button variant="outline" onClick={() => setGraphPodId(pods[0]?.id ?? 'demo-pod')}>
|
||||||
|
<BrainCircuitIcon data-icon="inline-start" />
|
||||||
|
Team memory
|
||||||
|
</Button>
|
||||||
<Button variant="outline" onClick={() => void refresh()} disabled={loading}>
|
<Button variant="outline" onClick={() => void refresh()} disabled={loading}>
|
||||||
<RefreshCwIcon data-icon="inline-start" />
|
<RefreshCwIcon data-icon="inline-start" />
|
||||||
Refresh
|
Refresh
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||||
|
import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared';
|
||||||
|
import { fetchPodGraph } from '../lib/graph.js';
|
||||||
|
|
||||||
|
type Mode = 'risk' | 'learn' | 'all';
|
||||||
|
|
||||||
|
const KIND_COLOR: Record<PodGraphNodeKind, string> = {
|
||||||
|
engineer: '#3B5BFF',
|
||||||
|
file: '#ECE7DA',
|
||||||
|
feature: '#F6C445',
|
||||||
|
collision: '#E2403A',
|
||||||
|
intervention: '#8b6cff',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EDGE: Record<PodGraphEdge['kind'], { c: string; w: number; dash?: boolean }> = {
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function NodeShape({ node }: { node: PodGraphNode }) {
|
||||||
|
const c = KIND_COLOR[node.kind];
|
||||||
|
const { x, y } = node;
|
||||||
|
switch (node.kind) {
|
||||||
|
case 'engineer':
|
||||||
|
return <rect x={x - 15} y={y - 15} width={30} height={30} fill={c} />;
|
||||||
|
case 'file':
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
x={x - 15}
|
||||||
|
y={y - 15}
|
||||||
|
width={30}
|
||||||
|
height={30}
|
||||||
|
fill="none"
|
||||||
|
stroke={c}
|
||||||
|
strokeWidth={2.6}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'feature':
|
||||||
|
return <circle cx={x} cy={y} r={17} fill={c} />;
|
||||||
|
case 'collision':
|
||||||
|
return <polygon points={`${x},${y - 18} ${x + 17},${y + 13} ${x - 17},${y + 13}`} fill={c} />;
|
||||||
|
case 'intervention':
|
||||||
|
return (
|
||||||
|
<polygon points={`${x},${y - 18} ${x + 18},${y} ${x},${y + 18} ${x - 18},${y}`} fill={c} />
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Highlight {
|
||||||
|
nodes: Set<string>;
|
||||||
|
edges: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Highlight | null {
|
||||||
|
if (selected) {
|
||||||
|
const es = graph.edges.filter((e) => e.source === selected || e.target === selected);
|
||||||
|
return {
|
||||||
|
nodes: new Set([selected, ...es.flatMap((e) => [e.source, e.target])]),
|
||||||
|
edges: new Set(es.map((e) => e.id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (mode === 'all') return null;
|
||||||
|
const kinds: PodGraphEdge['kind'][] =
|
||||||
|
mode === 'risk' ? ['collides', 'warns', 'learned_from'] : ['learned_from', 'warns'];
|
||||||
|
const collisions = new Set(graph.nodes.filter((n) => n.kind === 'collision').map((n) => n.id));
|
||||||
|
const es = graph.edges.filter(
|
||||||
|
(e) =>
|
||||||
|
kinds.includes(e.kind) ||
|
||||||
|
(mode === 'risk' && (collisions.has(e.target) || collisions.has(e.source))),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
nodes: new Set(es.flatMap((e) => [e.source, e.target])),
|
||||||
|
edges: new Set(es.map((e) => e.id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
|
||||||
|
{ 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)' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) {
|
||||||
|
const [graph, setGraph] = useState<PodGraph | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [mode, setMode] = useState<Mode>('risk');
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setGraph(null);
|
||||||
|
setError(null);
|
||||||
|
fetchPodGraph(podId)
|
||||||
|
.then((g) => alive && setGraph(g))
|
||||||
|
.catch((e: unknown) => alive && setError(e instanceof Error ? e.message : String(e)));
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [podId]);
|
||||||
|
|
||||||
|
const hi = useMemo(
|
||||||
|
() => (graph ? highlightFor(graph, mode, selected) : null),
|
||||||
|
[graph, mode, selected],
|
||||||
|
);
|
||||||
|
const nodeById = useMemo(() => new Map((graph?.nodes ?? []).map((n) => [n.id, n])), [graph]);
|
||||||
|
const sel = selected ? nodeById.get(selected) : undefined;
|
||||||
|
const relCount = selected
|
||||||
|
? (graph?.edges ?? []).filter((e) => e.source === selected || e.target === selected).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const dimNode = (id: string) => (hi ? !hi.nodes.has(id) : false);
|
||||||
|
const dimEdge = (id: string) => (hi ? !hi.edges.has(id) : false);
|
||||||
|
const hotEdge = (id: string) => (hi ? hi.edges.has(id) : false);
|
||||||
|
|
||||||
|
function pick(next: Mode) {
|
||||||
|
setMode(next);
|
||||||
|
setSelected(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pm-graph">
|
||||||
|
<style>{`
|
||||||
|
.pm-graph{--bg:#0c0c0e;--panel:#141417;--line:#2a2a31;--paper:#ECE7DA;--mut:#8d897e;--red:#E2403A;--yel:#F6C445;--vio:#8b6cff;
|
||||||
|
font-family:'Space Grotesk',system-ui,sans-serif;background:var(--bg);color:var(--paper);border:1px solid var(--line);border-radius:14px;overflow:hidden}
|
||||||
|
.pm-graph *{box-sizing:border-box}
|
||||||
|
.pm-hd{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:3px solid var(--paper)}
|
||||||
|
.pm-ttl{font-weight:800;font-size:18px;letter-spacing:.14em;text-transform:uppercase;font-family:Archivo,'Space Grotesk',sans-serif}
|
||||||
|
.pm-sub{font-size:10px;letter-spacing:.3em;color:var(--mut);text-transform:uppercase;margin-top:5px}
|
||||||
|
.pm-x{background:transparent;border:1px solid var(--line);color:var(--paper);font-size:11px;letter-spacing:.1em;text-transform:uppercase;padding:7px 12px;border-radius:2px;cursor:pointer}
|
||||||
|
.pm-x:hover{border-color:var(--paper)}
|
||||||
|
.pm-bar{display:flex;gap:8px;padding:12px 16px;border-bottom:1px solid var(--line);flex-wrap:wrap}
|
||||||
|
.pm-btn{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--paper);background:transparent;border:1px solid var(--line);padding:7px 12px;cursor:pointer;border-radius:2px}
|
||||||
|
.pm-btn:hover{border-color:var(--paper)}
|
||||||
|
.pm-btn.on{background:var(--red);border-color:var(--red);color:#fff}
|
||||||
|
.pm-grid{display:grid;grid-template-columns:180px 1fr 240px}
|
||||||
|
.pm-col{padding:14px}
|
||||||
|
.pm-railR{border-left:1px solid var(--line);background:#17171b}
|
||||||
|
.pm-st{font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:var(--mut);margin:2px 0 12px}
|
||||||
|
.pm-kpi{border:1px solid var(--line);border-left:5px solid var(--vio);padding:10px 11px;margin-bottom:10px}
|
||||||
|
.pm-num{font-weight:800;font-size:26px;line-height:.9;font-variant-numeric:tabular-nums;font-family:Archivo,sans-serif}
|
||||||
|
.pm-klab{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--mut);margin-top:6px}
|
||||||
|
.pm-kdet{font-size:10px;color:var(--mut);margin-top:5px;line-height:1.4}
|
||||||
|
.pm-canvas{background:var(--panel);border-left:1px solid var(--line);border-right:1px solid var(--line);min-height:472px}
|
||||||
|
.pm-canvas svg{width:100%;height:auto;display:block}
|
||||||
|
.pm-node{cursor:pointer}
|
||||||
|
.pm-lbl{font-weight:500;font-size:11px;letter-spacing:.06em;fill:var(--paper);text-transform:uppercase}
|
||||||
|
.pm-dim{opacity:.12;transition:opacity .25s}
|
||||||
|
.pm-dkind{font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:var(--mut)}
|
||||||
|
.pm-dname{font-weight:800;font-size:20px;margin:5px 0 8px;font-family:Archivo,sans-serif}
|
||||||
|
.pm-drow{display:flex;justify-content:space-between;font-size:12px;padding:6px 0;border-bottom:1px solid var(--line);color:var(--mut)}
|
||||||
|
.pm-drow b{color:var(--paper);font-weight:500}
|
||||||
|
.pm-note{font-size:12px;color:var(--mut);line-height:1.5;margin-top:10px}
|
||||||
|
.pm-legend{display:flex;gap:14px;flex-wrap:wrap;padding:10px 16px;border-top:1px solid var(--line);font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut)}
|
||||||
|
.pm-lg{display:flex;align-items:center;gap:6px}
|
||||||
|
.pm-sw{width:13px;height:13px;display:inline-block}
|
||||||
|
@media(max-width:760px){.pm-grid{grid-template-columns:1fr}.pm-railR{border-left:0;border-top:1px solid var(--line)}.pm-canvas{border:0;border-top:1px solid var(--line)}}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div className="pm-hd">
|
||||||
|
<div>
|
||||||
|
<div className="pm-ttl">Team memory</div>
|
||||||
|
<div className="pm-sub">What PodMan learned · {podId}</div>
|
||||||
|
</div>
|
||||||
|
<button className="pm-x" onClick={onClose}>
|
||||||
|
← Pods
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pm-bar">
|
||||||
|
<button
|
||||||
|
className={`pm-btn ${mode === 'risk' && !selected ? 'on' : ''}`}
|
||||||
|
onClick={() => pick('risk')}
|
||||||
|
>
|
||||||
|
Risk path
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`pm-btn ${mode === 'learn' && !selected ? 'on' : ''}`}
|
||||||
|
onClick={() => pick('learn')}
|
||||||
|
>
|
||||||
|
Learning edges
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`pm-btn ${mode === 'all' && !selected ? 'on' : ''}`}
|
||||||
|
onClick={() => pick('all')}
|
||||||
|
>
|
||||||
|
Whole graph
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p style={{ padding: '16px', color: '#ff7d76', fontSize: 13 }}>Graph error: {error}</p>
|
||||||
|
)}
|
||||||
|
{!graph && !error && (
|
||||||
|
<p style={{ padding: '16px', color: '#8d897e', fontSize: 13 }}>Loading graph…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{graph && (
|
||||||
|
<>
|
||||||
|
<div className="pm-grid">
|
||||||
|
<div className="pm-col">
|
||||||
|
<div className="pm-st">Workflow metrics</div>
|
||||||
|
{graph.metrics.map((m) => (
|
||||||
|
<div className="pm-kpi" key={m.label}>
|
||||||
|
<div className="pm-num">{m.value}</div>
|
||||||
|
<div className="pm-klab">{m.label}</div>
|
||||||
|
<div className="pm-kdet">{m.detail}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pm-canvas">
|
||||||
|
<svg viewBox="0 0 720 472" role="img" aria-label="PodMan team-memory graph">
|
||||||
|
{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 (
|
||||||
|
<line
|
||||||
|
key={e.id}
|
||||||
|
className={dimEdge(e.id) ? 'pm-dim' : undefined}
|
||||||
|
x1={a.x}
|
||||||
|
y1={a.y}
|
||||||
|
x2={b.x}
|
||||||
|
y2={b.y}
|
||||||
|
stroke={s.c}
|
||||||
|
strokeWidth={hotEdge(e.id) ? s.w + 1.6 : s.w}
|
||||||
|
strokeDasharray={s.dash ? '7 6' : undefined}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{graph.nodes.map((n) => (
|
||||||
|
<g
|
||||||
|
key={n.id}
|
||||||
|
className={`pm-node ${dimNode(n.id) ? 'pm-dim' : ''}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${n.kind}: ${n.label}`}
|
||||||
|
onClick={() => 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));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NodeShape node={n} />
|
||||||
|
<text className="pm-lbl" x={n.x} y={n.y + 33} textAnchor="middle">
|
||||||
|
{n.label.toUpperCase()}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pm-col pm-railR">
|
||||||
|
{sel ? (
|
||||||
|
<>
|
||||||
|
<div className="pm-dkind">{sel.kind}</div>
|
||||||
|
<div className="pm-dname">{sel.label}</div>
|
||||||
|
<div className="pm-drow">
|
||||||
|
<span>Status</span>
|
||||||
|
<b
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
sel.status === 'risk'
|
||||||
|
? '#E2403A'
|
||||||
|
: sel.status === 'learned'
|
||||||
|
? '#b7a4ff'
|
||||||
|
: '#ECE7DA',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sel.status}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
<div className="pm-drow">
|
||||||
|
<span>Relationships</span>
|
||||||
|
<b>{relCount}</b>
|
||||||
|
</div>
|
||||||
|
<div className="pm-note">{sel.summary}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="pm-dkind">Continual learning</div>
|
||||||
|
<div className="pm-dname">It learned</div>
|
||||||
|
<div className="pm-note">
|
||||||
|
Violet <b style={{ color: '#b7a4ff' }}>learned_from</b> edges are ownership
|
||||||
|
PodMan retained from accepted interventions — the graph gets sharper every
|
||||||
|
session. Click any node to trace its relationships.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pm-legend">
|
||||||
|
{LEGEND.map((l) => (
|
||||||
|
<span className="pm-lg" key={l.label}>
|
||||||
|
<span className="pm-sw" style={l.swatch} />
|
||||||
|
{l.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="pm-lg">
|
||||||
|
<span className="pm-sw" style={{ background: '#E2403A', height: 3 }} />
|
||||||
|
collides
|
||||||
|
</span>
|
||||||
|
<span className="pm-lg">
|
||||||
|
<span className="pm-sw" style={{ background: '#8b6cff', height: 3 }} />
|
||||||
|
learned_from
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { PodGraph } from '@podman/shared';
|
||||||
|
|
||||||
|
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8787';
|
||||||
|
|
||||||
|
/** Fetch a pod's continual-learning graph (team_model view, demo fallback). */
|
||||||
|
export async function fetchPodGraph(podId: string): Promise<PodGraph> {
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(podId)}/graph`);
|
||||||
|
if (!res.ok) throw new Error(`graph request failed: ${res.status}`);
|
||||||
|
return res.json() as Promise<PodGraph>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Continual-learning graph: PodMan's visible "it learned" surface. A render of
|
||||||
|
* the per-pod team_model — who owns/edits which files, where work collides, and
|
||||||
|
* what PodMan learned from accepted interventions (the `learned_from` edges).
|
||||||
|
*
|
||||||
|
* Served embedded in the `team_model` document; mirrored into `graph_nodes` /
|
||||||
|
* `graph_edges` collections so the model can be walked with MongoDB
|
||||||
|
* `$graphLookup`. See docs/graph.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PodGraphNodeKind = 'engineer' | 'feature' | 'file' | 'collision' | 'intervention';
|
||||||
|
|
||||||
|
export type PodGraphEdgeKind =
|
||||||
|
'owns' | 'editing' | 'touches' | 'collides' | 'warns' | 'learned_from';
|
||||||
|
|
||||||
|
/** `learned` marks a node PodMan retained from a past accepted intervention. */
|
||||||
|
export type PodGraphNodeStatus = 'stable' | 'active' | 'risk' | 'learned';
|
||||||
|
|
||||||
|
export interface PodGraphNode {
|
||||||
|
/** Stable graph id, e.g. "engineer:karti" or "file:auth.ts". */
|
||||||
|
id: string;
|
||||||
|
kind: PodGraphNodeKind;
|
||||||
|
label: string;
|
||||||
|
summary: string;
|
||||||
|
/** 0–1 relative importance — drives node size in the viz. */
|
||||||
|
weight: number;
|
||||||
|
status: PodGraphNodeStatus;
|
||||||
|
/** Layout position on the SVG canvas (viewBox 0..720 x 0..472). */
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PodGraphEdge {
|
||||||
|
id: string;
|
||||||
|
/** Source node id. */
|
||||||
|
source: string;
|
||||||
|
/** Target node id. */
|
||||||
|
target: string;
|
||||||
|
kind: PodGraphEdgeKind;
|
||||||
|
label: string;
|
||||||
|
/** 0–1 confidence/strength — drives edge thickness. */
|
||||||
|
strength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PodGraphMetric {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A point-in-time render of a pod's team_model. */
|
||||||
|
export interface PodGraph {
|
||||||
|
podId: string;
|
||||||
|
/** ISO timestamp the snapshot was generated. */
|
||||||
|
generatedAt: string;
|
||||||
|
nodes: PodGraphNode[];
|
||||||
|
edges: PodGraphEdge[];
|
||||||
|
metrics: PodGraphMetric[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One node as a standalone document in the `graph_nodes` collection. */
|
||||||
|
export interface GraphNodeDoc extends PodGraphNode {
|
||||||
|
podId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One edge as a standalone document in the `graph_edges` collection (for $graphLookup). */
|
||||||
|
export interface GraphEdgeDoc extends PodGraphEdge {
|
||||||
|
podId: string;
|
||||||
|
}
|
||||||
@@ -9,3 +9,14 @@ export type {
|
|||||||
SuggestedActionKind,
|
SuggestedActionKind,
|
||||||
} from './intervention.js';
|
} from './intervention.js';
|
||||||
export * from './messages.js';
|
export * from './messages.js';
|
||||||
|
export type {
|
||||||
|
PodGraph,
|
||||||
|
PodGraphNode,
|
||||||
|
PodGraphEdge,
|
||||||
|
PodGraphMetric,
|
||||||
|
PodGraphNodeKind,
|
||||||
|
PodGraphEdgeKind,
|
||||||
|
PodGraphNodeStatus,
|
||||||
|
GraphNodeDoc,
|
||||||
|
GraphEdgeDoc,
|
||||||
|
} from './graph.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user