feat(graph): demo-ready materializer + per-pod Team memory action
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 <noreply@anthropic.com>
This commit is contained in:
+102
-16
@@ -35,9 +35,24 @@ export function normalizeFile(f: string): string {
|
|||||||
return f
|
return f
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/^["']|["']$/g, '')
|
.replace(/^["']|["']$/g, '')
|
||||||
|
.replace(/^[ACDMRTU?!]{1,2}\s+/, '')
|
||||||
.replace(/^\.\//, '');
|
.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<PodGraphNodeStatus, number> = {
|
const STATUS_RANK: Record<PodGraphNodeStatus, number> = {
|
||||||
stable: 0,
|
stable: 0,
|
||||||
active: 1,
|
active: 1,
|
||||||
@@ -166,32 +181,67 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
|||||||
label: o.engineerId,
|
label: o.engineerId,
|
||||||
status: recent ? 'active' : undefined,
|
status: recent ? 'active' : undefined,
|
||||||
});
|
});
|
||||||
if (o.currentFile) {
|
const file = o.currentFile ? normalizeFile(o.currentFile) : '';
|
||||||
const file = normalizeFile(o.currentFile);
|
if (isFilePath(file)) {
|
||||||
const f = upsertNode(b, 'file', file, { label: file });
|
const f = upsertNode(b, 'file', file, { label: file });
|
||||||
upsertEdge(b, eng, f, 'editing', o.activity ?? 'edits', Math.max(0.4, o.confidence ?? 0.5));
|
upsertEdge(b, eng, f, 'editing', o.activity ?? 'edits', Math.max(0.4, o.confidence ?? 0.5));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Collisions: the detected overlaps (fused git + vision).
|
// Collisions referenced by accepted outcomes are the "learned" money path — they
|
||||||
|
// always survive the cap so the learned_from beat is never dropped.
|
||||||
|
const priorityCol = new Set<string>();
|
||||||
|
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<string, (typeof collisionDocs)[number]>();
|
const collisionById = new Map<string, (typeof collisionDocs)[number]>();
|
||||||
|
const colNodeFor = new Map<string, string | null>();
|
||||||
|
const sigToNode = new Map<string, string>();
|
||||||
|
let distinctCollisions = 0;
|
||||||
for (const col of collisionDocs) {
|
for (const col of collisionDocs) {
|
||||||
collisionById.set(col.id, col);
|
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, {
|
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',
|
status: 'risk',
|
||||||
weight: SEVERITY_WEIGHT[col.severity] ?? 0.7,
|
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' : ''
|
(col as { memorySignature?: string }).memorySignature ? ' · seen before' : ''
|
||||||
}`,
|
}`,
|
||||||
});
|
});
|
||||||
const file = normalizeFile(col.file);
|
|
||||||
const fNode = upsertNode(b, 'file', file, { label: file, status: 'risk' });
|
const fNode = upsertNode(b, 'file', file, { label: file, status: 'risk' });
|
||||||
upsertEdge(b, fNode, cNode, 'touches', 'hot', 0.6);
|
upsertEdge(b, fNode, cNode, 'touches', 'hot', 0.6);
|
||||||
for (const name of col.engineers) {
|
for (const name of col.engineers) {
|
||||||
const eng = upsertNode(b, 'engineer', name, { label: name });
|
const eng = upsertNode(b, 'engineer', name, { label: name });
|
||||||
upsertEdge(b, eng, cNode, 'collides', 'in', SEVERITY_WEIGHT[col.severity] ?? 0.7);
|
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
|
// 4. Git truth (engineer_states): mark unpushed work and confirm editing on
|
||||||
@@ -212,10 +262,16 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Interventions: what PodMan offered for each collision.
|
// 5. Interventions: collapse to one (most recent) per surviving collision.
|
||||||
const interventionById = new Map<string, (typeof interventionDocs)[number]>();
|
const interventionById = new Map<string, (typeof interventionDocs)[number]>();
|
||||||
for (const iv of interventionDocs) {
|
const ivNodeForCol = new Map<string, string>();
|
||||||
|
const sortedIvs = [...interventionDocs].sort((a, b) =>
|
||||||
|
String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')),
|
||||||
|
);
|
||||||
|
for (const iv of sortedIvs) {
|
||||||
interventionById.set(iv.id, iv);
|
interventionById.set(iv.id, iv);
|
||||||
|
const colNode = colNodeFor.get(iv.collisionId);
|
||||||
|
if (!colNode || ivNodeForCol.has(colNode)) continue;
|
||||||
const ivNode = upsertNode(b, 'intervention', iv.id, {
|
const ivNode = upsertNode(b, 'intervention', iv.id, {
|
||||||
label:
|
label:
|
||||||
iv.suggestedAction?.kind === 'open_sync_pr'
|
iv.suggestedAction?.kind === 'open_sync_pr'
|
||||||
@@ -225,9 +281,8 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
|||||||
: 'watch',
|
: 'watch',
|
||||||
summary: iv.message,
|
summary: iv.message,
|
||||||
});
|
});
|
||||||
if (b.nodes.has(nodeKey('collision', iv.collisionId))) {
|
upsertEdge(b, colNode, ivNode, 'warns', 'nudges', 0.85);
|
||||||
upsertEdge(b, nodeKey('collision', iv.collisionId), ivNode, 'warns', 'nudges', 0.85);
|
ivNodeForCol.set(colNode, ivNode);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Outcomes: the supervised learning signal -> learned_from edges + owns.
|
// 6. Outcomes: the supervised learning signal -> learned_from edges + owns.
|
||||||
@@ -237,16 +292,41 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
|||||||
const col = iv ? collisionById.get(iv.collisionId) : collisionById.get(out.collisionId);
|
const col = iv ? collisionById.get(iv.collisionId) : collisionById.get(out.collisionId);
|
||||||
if (!col) continue;
|
if (!col) continue;
|
||||||
const file = normalizeFile(col.file);
|
const file = normalizeFile(col.file);
|
||||||
|
if (!isFilePath(file)) continue;
|
||||||
const owner =
|
const owner =
|
||||||
(out as { learnedOwner?: string }).learnedOwner ?? ownership[file] ?? col.engineers[0];
|
(out as { learnedOwner?: string }).learnedOwner ?? ownership[file] ?? col.engineers[0];
|
||||||
if (!owner) continue;
|
if (!owner) continue;
|
||||||
const engNode = upsertNode(b, 'engineer', owner, { label: owner, status: 'learned' });
|
const engNode = upsertNode(b, 'engineer', owner, { label: owner, status: 'learned' });
|
||||||
const fNode = upsertNode(b, 'file', file, { label: file });
|
const fNode = upsertNode(b, 'file', file, { label: file });
|
||||||
upsertEdge(b, engNode, fNode, 'owns', 'owns', 0.85);
|
upsertEdge(b, engNode, fNode, 'owns', 'owns', 0.85);
|
||||||
const ivKey = iv ? nodeKey('intervention', iv.id) : null;
|
const cNode = colNodeFor.get(col.id);
|
||||||
if (ivKey && b.nodes.has(ivKey)) {
|
const ivNode = cNode ? ivNodeForCol.get(cNode) : undefined;
|
||||||
upsertNode(b, 'intervention', iv!.id, { status: 'learned' });
|
if (ivNode) {
|
||||||
upsertEdge(b, ivKey, engNode, 'learned_from', `learned: owns ${file}`, 0.6);
|
const ivObj = b.nodes.get(ivNode);
|
||||||
|
if (ivObj) ivObj.status = 'learned';
|
||||||
|
upsertEdge(b, ivNode, engNode, 'learned_from', `learned: owns ${file}`, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune test-artifact engineers, then anything left orphaned by that.
|
||||||
|
const dropNode = (id: string) => {
|
||||||
|
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<PodGraph | nul
|
|||||||
|
|
||||||
const acceptedReal = outcomeDocs.filter((o) => o.accepted && o.wasRealCollision).length;
|
const acceptedReal = outcomeDocs.filter((o) => o.accepted && o.wasRealCollision).length;
|
||||||
const totalOutcomes = outcomeDocs.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[] = [
|
const metrics: PodGraphMetric[] = [
|
||||||
{
|
{
|
||||||
label: 'Learned owners',
|
label: 'Learned owners',
|
||||||
|
|||||||
@@ -268,10 +268,6 @@ 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
|
||||||
@@ -342,6 +338,7 @@ export default function App() {
|
|||||||
onRemoveMember={handleRemoveMember}
|
onRemoveMember={handleRemoveMember}
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onOpenGraph={setGraphPodId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useState } from 'react';
|
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 type { Pod, PodInput } from '@podman/shared';
|
||||||
import { Avatar, AvatarBadge, AvatarFallback, AvatarGroup } from '@/components/ui/avatar';
|
import { Avatar, AvatarBadge, AvatarFallback, AvatarGroup } from '@/components/ui/avatar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -42,6 +49,7 @@ export function PodCard({
|
|||||||
onRemoveMember: _onRemoveMember,
|
onRemoveMember: _onRemoveMember,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onOpenGraph,
|
||||||
}: {
|
}: {
|
||||||
pod: Pod;
|
pod: Pod;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
@@ -52,6 +60,7 @@ export function PodCard({
|
|||||||
onRemoveMember: (id: string, name: string) => void;
|
onRemoveMember: (id: string, name: string) => void;
|
||||||
onUpdate: (id: string, patch: PodInput) => void;
|
onUpdate: (id: string, patch: PodInput) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
|
onOpenGraph: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [newMember, setNewMember] = useState('');
|
const [newMember, setNewMember] = useState('');
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
@@ -100,6 +109,10 @@ export function PodCard({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem onSelect={() => onOpenGraph(pod.id)}>
|
||||||
|
<BrainCircuitIcon />
|
||||||
|
Team memory
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => setEditing(true)}>Edit pod</DropdownMenuItem>
|
<DropdownMenuItem onSelect={() => setEditing(true)}>Edit pod</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
|||||||
Reference in New Issue
Block a user