Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 98 additions & 80 deletions packages/mcp-server/src/services/graph-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
Neo4jParams,
Neo4jNode,
Neo4jContributor,
Neo4jPathSegment,
AnalysisResults,
WorkloadData,
CapacityAnalysis,
Expand Down Expand Up @@ -349,9 +348,9 @@ export class GraphService {
}
query = `
MATCH (n:WorkItem {id: $node_id})
OPTIONAL MATCH (n)-[r1:DEPENDS_ON]->(dep:WorkItem)
OPTIONAL MATCH (dependent:WorkItem)-[r2:DEPENDS_ON]->(n)
RETURN n,
OPTIONAL MATCH (n)<-[:EDGE_SOURCE]-(:Edge {type: 'DEPENDS_ON'})-[:EDGE_TARGET]->(dep:WorkItem)
OPTIONAL MATCH (dependent:WorkItem)<-[:EDGE_SOURCE]-(:Edge {type: 'DEPENDS_ON'})-[:EDGE_TARGET]->(n)
RETURN n,
COLLECT(DISTINCT dep) as dependencies,
COLLECT(DISTINCT dependent) as dependents
`;
Expand Down Expand Up @@ -884,24 +883,31 @@ export class GraphService {
};
}

// Create the relationship
// Edges are first-class Edge NODES (e)-[:EDGE_SOURCE]->(source),
// (e)-[:EDGE_TARGET]->(target) — the SAME model the GraphQL server and
// web UI use. (Previously MCP created a direct (source)-[:TYPE]->(target)
// relationship, which the web never rendered, so AI-created edges were
// invisible to humans.) MERGE on the source/target/type triple keeps it
// idempotent like the old code.
const createQuery = `
MATCH (source:WorkItem {id: $source_id})
MATCH (target:WorkItem {id: $target_id})
MERGE (source)-[r:${type} {
weight: $weight,
metadata: $metadata,
createdAt: $now
}]->(target)
MERGE (source)<-[:EDGE_SOURCE]-(r:Edge {type: $type})-[:EDGE_TARGET]->(target)
ON CREATE SET r.id = 'edge-' + randomUUID(),
r.weight = $weight,
r.metadata = $metadata,
r.createdAt = datetime()
ON MATCH SET r.weight = $weight,
r.metadata = $metadata
RETURN r, source, target
`;

const params = {
source_id,
target_id,
type,
weight,
metadata: JSON.stringify(metadata),
now: new Date().toISOString()
metadata: JSON.stringify(metadata)
};

const result = await session.run(createQuery, params);
Expand Down Expand Up @@ -931,13 +937,14 @@ export class GraphService {
const { source_id, target_id, type } = args;

const deleteQuery = `
MATCH (source:WorkItem {id: $source_id})-[r:${type}]->(target:WorkItem {id: $target_id})
DELETE r
MATCH (source:WorkItem {id: $source_id})<-[:EDGE_SOURCE]-(r:Edge {type: $type})-[:EDGE_TARGET]->(target:WorkItem {id: $target_id})
WITH r, source, target
DETACH DELETE r
RETURN source, target
`;

const result = await session.run(deleteQuery, { source_id, target_id });
const result = await session.run(deleteQuery, { source_id, target_id, type });

if (result.records.length === 0) {
return {
content: [{
Expand Down Expand Up @@ -988,20 +995,25 @@ export class GraphService {
RETURN n, COLLECT(DISTINCT c) as contributors
`;

// Count relationships
// Count relationships — edges are Edge NODES joined via EDGE_SOURCE /
// EDGE_TARGET (the same model the web uses), not direct WorkItem rels.
const relCountQuery = `
MATCH (n:WorkItem {id: $node_id})
OPTIONAL MATCH (n)-[rel]-(related:WorkItem)
RETURN count(DISTINCT rel) as totalRelationships
OPTIONAL MATCH (n)<-[:EDGE_SOURCE|EDGE_TARGET]-(e:Edge)
RETURN count(DISTINCT e) as totalRelationships
`;

// Get relationships with pagination
const relationshipsQuery = `
MATCH (n:WorkItem {id: $node_id})
OPTIONAL MATCH (n)-[rel]-(related:WorkItem)
RETURN rel, related,
CASE WHEN startNode(rel) = n THEN 'outgoing' ELSE 'incoming' END as direction
ORDER BY type(rel), related.title
OPTIONAL MATCH (n)<-[se:EDGE_SOURCE|EDGE_TARGET]-(e:Edge)
OPTIONAL MATCH (e)-[:EDGE_SOURCE]->(src:WorkItem)
OPTIONAL MATCH (e)-[:EDGE_TARGET]->(tgt:WorkItem)
WITH e,
CASE WHEN type(se) = 'EDGE_SOURCE' THEN 'outgoing' ELSE 'incoming' END as direction,
CASE WHEN type(se) = 'EDGE_SOURCE' THEN tgt ELSE src END as related
RETURN e as rel, related, direction
ORDER BY e.type, related.title
SKIP $relationships_offset
LIMIT $relationships_limit
`;
Expand Down Expand Up @@ -1046,12 +1058,12 @@ export class GraphService {
const relationships = relationshipsResult.records
.filter(record => record.get('rel') !== null && record.get('related') !== null)
.map(record => {
const rel = record.get('rel');
const rel = record.get('rel'); // an Edge NODE
const related = record.get('related');
const direction = record.get('direction');

return {
type: rel.type,
type: rel.properties.type,
direction,
target_node: related.properties,
relationship_properties: rel.properties
Expand Down Expand Up @@ -1106,15 +1118,22 @@ export class GraphService {
const limitInt = typeof limit === 'number' ? Math.floor(limit) : parseInt(String(limit), 10) || 10;
const offsetInt = typeof offset === 'number' ? Math.floor(offset) : parseInt(String(offset), 10) || 0;

// Count query to get total number of paths
// Edges are Edge NODES, so each logical hop between two WorkItems is two
// physical hops (WorkItem)<-[:EDGE_SOURCE]-(:Edge)-[:EDGE_TARGET]->(WorkItem).
// Traverse the EDGE_SOURCE/EDGE_TARGET structure (depth doubled) and
// project the path back to its WorkItem nodes and Edge nodes.
const physicalDepth = maxDepthInt * 2;
const countQuery = `
MATCH path = allShortestPaths((start:WorkItem {id: $start_id})-[*1..${maxDepthInt}]-(end:WorkItem {id: $end_id}))
MATCH path = allShortestPaths((start:WorkItem {id: $start_id})-[:EDGE_SOURCE|EDGE_TARGET*1..${physicalDepth}]-(end:WorkItem {id: $end_id}))
RETURN count(path) as total
`;

const query = `
MATCH path = allShortestPaths((start:WorkItem {id: $start_id})-[*1..${maxDepthInt}]-(end:WorkItem {id: $end_id}))
RETURN path, length(path) as pathLength
MATCH path = allShortestPaths((start:WorkItem {id: $start_id})-[:EDGE_SOURCE|EDGE_TARGET*1..${physicalDepth}]-(end:WorkItem {id: $end_id}))
WITH path,
[x IN nodes(path) WHERE x:WorkItem | x] as wnodes,
[x IN nodes(path) WHERE x:Edge | x] as wedges
RETURN wnodes, wedges, size(wedges) as pathLength
ORDER BY pathLength ASC
SKIP $offset
LIMIT $limit
Expand Down Expand Up @@ -1148,20 +1167,12 @@ export class GraphService {
}

const paths = result.records.map(record => {
const path = record.get('path');
const pathLength = record.get('pathLength')?.toNumber?.() || 0;

const nodes = path.segments.map((segment: Neo4jPathSegment, index: number) => {
if (index === 0) {
return [segment.start.properties, segment.end.properties];
} else {
return segment.end.properties;
}
}).flat();

const relationships = path.segments.map((segment: Neo4jPathSegment) => ({
type: segment.relationship.type,
properties: segment.relationship.properties
const nodes = (record.get('wnodes') || []).map((n: Neo4jNode) => n.properties);
const relationships = (record.get('wedges') || []).map((e: Neo4jNode) => ({
type: e.properties.type,
properties: e.properties
}));

return { nodes, relationships, length: pathLength };
Expand Down Expand Up @@ -1192,16 +1203,23 @@ export class GraphService {
const limit = Math.floor(Number(args.limit || args.max_cycles || 10));
const offset = Math.floor(Number(args.offset || 0));

// DEPENDS_ON edges are Edge NODES, so a logical hop is
// (a)<-[:EDGE_SOURCE]-(:Edge)-[:EDGE_TARGET]->(b). Use a quantified path
// pattern (Neo4j 5) to find cycles of 2..10 logical hops, then keep only
// the WorkItem nodes for the result.
const cyclePattern = `(n:WorkItem) ((:WorkItem)<-[:EDGE_SOURCE]-(:Edge {type: 'DEPENDS_ON'})-[:EDGE_TARGET]->(:WorkItem)){2,10} (n)`;

// Count query for total cycles
const countQuery = `
MATCH path = (n:WorkItem)-[:DEPENDS_ON*2..10]->(n)
MATCH path = ${cyclePattern}
RETURN count(path) as total
`;

// Main query with pagination using parameterized queries
const query = `
MATCH path = (n:WorkItem)-[:DEPENDS_ON*2..10]->(n)
RETURN path, length(path) as cycleLength
MATCH path = ${cyclePattern}
WITH path, [x IN nodes(path) WHERE x:WorkItem | x] as cycleNodes
RETURN cycleNodes, size(cycleNodes) - 1 as cycleLength
ORDER BY cycleLength ASC
SKIP $offset
LIMIT $limit
Expand All @@ -1217,17 +1235,8 @@ export class GraphService {
});

const cycles = result.records.map(record => {
const path = record.get('path');
const cycleLength = record.get('cycleLength')?.toNumber?.() || 0;

const nodes = path.segments.map((segment: Neo4jPathSegment, index: number) => {
if (index === 0) {
return [segment.start.properties, segment.end.properties];
} else {
return segment.end.properties;
}
}).flat();

const nodes = (record.get('cycleNodes') || []).map((n: Neo4jNode) => n.properties);
return { nodes, length: cycleLength };
});

Expand Down Expand Up @@ -1607,9 +1616,9 @@ export class GraphService {
count(n) as total_nodes,
collect(DISTINCT n.type) as node_types,
collect(DISTINCT n.status) as statuses,
avg(size((n)-[:DEPENDS_ON]->())) as avg_dependencies,
max(size((n)-[:DEPENDS_ON]->())) as max_dependencies,
count(CASE WHEN size((n)-[:DEPENDS_ON]->()) = 0 THEN 1 END) as isolated_nodes
avg(COUNT { (n)<-[:EDGE_SOURCE]-(:Edge {type: 'DEPENDS_ON'})-[:EDGE_TARGET]->(:WorkItem) }) as avg_dependencies,
max(COUNT { (n)<-[:EDGE_SOURCE]-(:Edge {type: 'DEPENDS_ON'})-[:EDGE_TARGET]->(:WorkItem) }) as max_dependencies,
count(CASE WHEN COUNT { (n)<-[:EDGE_SOURCE]-(:Edge {type: 'DEPENDS_ON'})-[:EDGE_TARGET]->(:WorkItem) } = 0 THEN 1 END) as isolated_nodes
`;

try {
Expand Down Expand Up @@ -1675,7 +1684,7 @@ export class GraphService {
const query = `
MATCH (n:WorkItem)
${whereClause}
OPTIONAL MATCH (n)-[:DEPENDS_ON]->(dep:WorkItem)
OPTIONAL MATCH (n)<-[:EDGE_SOURCE]-(:Edge {type: 'DEPENDS_ON'})-[:EDGE_TARGET]->(dep:WorkItem)
WITH n, collect(dep) as dependencies
RETURN
count(n) as total_nodes,
Expand Down Expand Up @@ -1706,7 +1715,7 @@ export class GraphService {
const query = `
MATCH (n:WorkItem)
${whereClause}
OPTIONAL MATCH (n)<-[:DEPENDS_ON]-(dependent:WorkItem)
OPTIONAL MATCH (n)<-[:EDGE_TARGET]-(:Edge {type: 'DEPENDS_ON'})-[:EDGE_SOURCE]->(dependent:WorkItem)
WITH n, count(dependent) as dependent_count
WHERE dependent_count > 3
RETURN
Expand Down Expand Up @@ -1834,7 +1843,7 @@ export class GraphService {
const bottleneckQuery = `
MATCH (n:WorkItem)
${whereClause}
OPTIONAL MATCH (n)<-[:DEPENDS_ON]-(dependent:WorkItem)
OPTIONAL MATCH (n)<-[:EDGE_TARGET]-(:Edge {type: 'DEPENDS_ON'})-[:EDGE_SOURCE]->(dependent:WorkItem)
WITH n, collect(dependent) as dependents, count(dependent) as dependent_count
WHERE dependent_count > 0
RETURN
Expand All @@ -1853,7 +1862,7 @@ export class GraphService {
const blockedChainQuery = `
MATCH (blocked:WorkItem {status: 'BLOCKED'})
${whereClause ? whereClause.replace('n.teamId', 'blocked.teamId') : ''}
OPTIONAL MATCH (blocked)-[:DEPENDS_ON]->(blocker:WorkItem)
OPTIONAL MATCH (blocked)<-[:EDGE_SOURCE]-(:Edge {type: 'DEPENDS_ON'})-[:EDGE_TARGET]->(blocker:WorkItem)
WHERE blocker.status IN ['PROPOSED', 'PLANNED', 'IN_PROGRESS']
RETURN
blocked.id as blocked_id,
Expand Down Expand Up @@ -2227,17 +2236,20 @@ export class GraphService {
const query = `
MATCH (source:WorkItem {id: $source_id})
MATCH (target:WorkItem {id: $target_id})
CREATE (source)-[r:${type} {
weight: $weight,
metadata: $metadata,
createdAt: datetime()
}]->(target)
MERGE (source)<-[:EDGE_SOURCE]-(r:Edge {type: $type})-[:EDGE_TARGET]->(target)
ON CREATE SET r.id = 'edge-' + randomUUID(),
r.weight = $weight,
r.metadata = $metadata,
r.createdAt = datetime()
ON MATCH SET r.weight = $weight,
r.metadata = $metadata
RETURN r
`;

const result = await sessionOrTx.run(query, {
source_id,
target_id,
type,
weight,
metadata: JSON.stringify(metadata)
});
Expand All @@ -2253,12 +2265,12 @@ export class GraphService {
const { source_id, target_id, type } = params;

const query = `
MATCH (source:WorkItem {id: $source_id})-[r:${type}]->(target:WorkItem {id: $target_id})
DELETE r
MATCH (source:WorkItem {id: $source_id})<-[:EDGE_SOURCE]-(r:Edge {type: $type})-[:EDGE_TARGET]->(target:WorkItem {id: $target_id})
DETACH DELETE r
RETURN count(r) as deleted_count
`;

const result = await sessionOrTx.run(query, { source_id, target_id });
const result = await sessionOrTx.run(query, { source_id, target_id, type });
const deletedCount = result.records[0]?.get('deleted_count').toNumber() || 0;

if (deletedCount === 0) {
Expand Down Expand Up @@ -2515,7 +2527,7 @@ export class GraphService {
const query = `
MATCH (c:Contributor {id: $contributorId})-[:CONTRIBUTES_TO]->(w:WorkItem)
WHERE w.status IN $statusFilter
OPTIONAL MATCH (w)-[:DEPENDS_ON]->(dep:WorkItem)
OPTIONAL MATCH (w)<-[:EDGE_SOURCE]-(:Edge {type: 'DEPENDS_ON'})-[:EDGE_TARGET]->(dep:WorkItem)
RETURN w,
count(dep) as dependencyCount,
collect(DISTINCT dep.title)[0..3] as sampleDependencies
Expand Down Expand Up @@ -3233,7 +3245,7 @@ export class GraphService {
const query = `
MATCH (g:Graph {id: $graphId})
OPTIONAL MATCH (g)<-[:BELONGS_TO]-(w:WorkItem)
OPTIONAL MATCH (w)-[e:DEPENDS_ON|BLOCKS|RELATES_TO|CONTAINS|PART_OF]-(:WorkItem)
OPTIONAL MATCH (w)<-[:EDGE_SOURCE]-(e:Edge)
RETURN g,
count(DISTINCT w) as nodeCount,
count(DISTINCT e) as edgeCount,
Expand Down Expand Up @@ -3289,7 +3301,8 @@ export class GraphService {
const query = `
MATCH (g:Graph {id: $graphId})
OPTIONAL MATCH (g)<-[:BELONGS_TO]-(w:WorkItem)
OPTIONAL MATCH (w)-[e:DEPENDS_ON|BLOCKS|RELATES_TO|CONTAINS|PART_OF]-(:WorkItem)
// Edges are Edge NODES; count those whose source is in this graph.
OPTIONAL MATCH (w)<-[:EDGE_SOURCE]-(e:Edge)
WITH g, collect(DISTINCT w) as items, count(DISTINCT e) as edgeCount
// Return the raw type/status lists and tally them in JS. A
// CALL { UNWIND items ... } subquery returns ZERO rows for an empty
Expand All @@ -3301,8 +3314,8 @@ export class GraphService {
[x IN items | x.status] as statuses
CALL {
WITH g
OPTIONAL MATCH (g)<-[:BELONGS_TO]-(b:WorkItem)-[r:BLOCKS]->(:WorkItem)
WITH b, count(r) as blocksCount
OPTIONAL MATCH (g)<-[:BELONGS_TO]-(b:WorkItem)<-[:EDGE_SOURCE]-(e:Edge {type: 'BLOCKS'})-[:EDGE_TARGET]->(:WorkItem)
WITH b, count(e) as blocksCount
WHERE b IS NOT NULL AND blocksCount > 0
ORDER BY blocksCount DESC LIMIT 5
RETURN collect({id: b.id, title: b.title, blocksCount: blocksCount}) as blockers
Expand Down Expand Up @@ -3633,18 +3646,23 @@ export class GraphService {

// Clone edges if requested
if (args.includeEdges !== false && clonedNodes > 0) {
// Edges are Edge NODES — clone each source Edge node (with its
// real type) between the corresponding cloned WorkItems.
const cloneEdgesQuery = `
MATCH (newG:Graph {id: $newGraphId})<-[:BELONGS_TO]-(newW:WorkItem)
MATCH (sourceG:Graph {id: $sourceGraphId})<-[:BELONGS_TO]-(sourceW:WorkItem)
WHERE sourceW.id = newW.originalId
MATCH (sourceW)-[r:DEPENDS_ON|BLOCKS|RELATES_TO|CONTAINS|PART_OF]->(targetW:WorkItem)-[:BELONGS_TO]->(sourceG)
MATCH (sourceW)<-[:EDGE_SOURCE]-(e:Edge)-[:EDGE_TARGET]->(targetW:WorkItem)-[:BELONGS_TO]->(sourceG)
MATCH (newG)<-[:BELONGS_TO]-(newTargetW:WorkItem)
WHERE newTargetW.originalId = targetW.id
// Preserve the real relationship type — the previous code
// hard-coded :DEPENDS_ON, silently rewriting every BLOCKS /
// RELATES_TO / CONTAINS / PART_OF edge into a DEPENDS_ON on clone.
CALL apoc.create.relationship(newW, type(r), { weight: r.weight, metadata: r.metadata }, newTargetW) YIELD rel
RETURN count(rel) as edgeCount
CREATE (newW)<-[:EDGE_SOURCE]-(newE:Edge {
id: 'edge-' + randomUUID(),
type: e.type,
weight: e.weight,
metadata: e.metadata,
createdAt: datetime()
})-[:EDGE_TARGET]->(newTargetW)
RETURN count(newE) as edgeCount
`;

const edgesResult = await tx.run(cloneEdgesQuery, {
Expand Down
Loading
Loading