Skip to content

Commit 2eb4db6

Browse files
fix(graph): preserve filtered layout contracts
1 parent d3aa831 commit 2eb4db6

13 files changed

Lines changed: 252 additions & 31 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ Follow-up audit of the graph, Team / Pro / licensing, and relay surfaces after 0
2525
before personal receipt reads, so the deployment service account remains available for
2626
shared automation without bypassing personal-folder ownership. The standalone
2727
read-only graph endpoint also disables lazy write-on-read backfill.
28+
- Repository indexing now creates a first-time Team workspace through the same
29+
privacy-aware path as remember/import/session writes, instead of silently creating a
30+
shared, unowned folder for the authenticated user.
2831

2932
### Fixed
3033

@@ -33,6 +36,12 @@ Follow-up audit of the graph, Team / Pro / licensing, and relay surfaces after 0
3336
page through every live repo-associated memory instead of clearing the bridge and
3437
stopping at 5,000, and Git impact parsing uses NUL-delimited paths without rewriting
3538
valid filename characters.
39+
- Graph layer predicates are applied before workspace and code-edge response caps, and an
40+
explicit all-off layer selection remains empty instead of reverting to every layer.
41+
Layout preset and custom link-distance changes also recompute component centers while
42+
preserving the existing graph data and node objects.
43+
Filter reloads also tolerate transient graph-data invalidation, so restoring layers
44+
redraws the canvas instead of leaving the explorer list beside an empty graph.
3645
- Oversized audio/video resources are rejected before transcription begins. A blank
3746
`ENGRAPHIS_GRAPH_TOKEN` now correctly falls back to `ENGRAPHIS_API_TOKEN`.
3847
- The relay sweeps `trial_pending` rows that lapsed over a day ago. Previously a magic

engraphis/core/store.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -986,7 +986,9 @@ def edges_in_scope(self, flt: Optional[SearchFilter] = None,
986986
if flt and flt.repo_id:
987987
sql += " AND (repo_id=? OR repo_id IS NULL)" if flt.include_ancestors else " AND repo_id=?"
988988
params.append(flt.repo_id)
989-
if flt and flt.graph_layers:
989+
if flt and flt.graph_layers is not None:
990+
if not flt.graph_layers:
991+
return []
990992
marks = ",".join("?" for _ in flt.graph_layers)
991993
sql += f" AND layer IN ({marks})"
992994
params.extend(_enum(layer) for layer in flt.graph_layers)
@@ -1165,9 +1167,17 @@ def list_symbols_page(self, repo_id: str, *,
11651167
params.append(max(1, int(limit)))
11661168
return [dict(row) for row in self.conn.execute(sql, params).fetchall()]
11671169

1168-
def list_code_edges(self, repo_id: str, *, limit: Optional[int] = None) -> list[dict]:
1169-
sql = "SELECT * FROM code_edges WHERE repo_id=? ORDER BY file, line, id"
1170+
def list_code_edges(self, repo_id: str, *, limit: Optional[int] = None,
1171+
layers: Optional[list[GraphLayer]] = None) -> list[dict]:
1172+
sql = "SELECT * FROM code_edges WHERE repo_id=?"
11701173
params: list[Any] = [repo_id]
1174+
if layers is not None:
1175+
if not layers:
1176+
return []
1177+
marks = ",".join("?" for _ in layers)
1178+
sql += f" AND layer IN ({marks})"
1179+
params.extend(_enum(layer) for layer in layers)
1180+
sql += " ORDER BY file, line, id"
11711181
if limit is not None:
11721182
sql += " LIMIT ?"
11731183
params.append(max(0, int(limit))) # never -1 == SQLite "unlimited"

engraphis/inspector/app.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -432,13 +432,16 @@ async def receipts_verify(workspace: str):
432432
return svc().verify_receipts(workspace=workspace)
433433

434434
@app.get("/api/graph")
435-
async def graph(workspace: str, limit: int = 2000, layers: str = "",
436-
include_code: bool = False, repo: Optional[str] = None):
435+
async def graph(workspace: str, limit: int = 2000,
436+
layers: Optional[str] = None, include_code: bool = False,
437+
repo: Optional[str] = None):
437438
"""Entity-relation network for the Graph tab -- same
438439
:meth:`MemoryService.graph` the v1-look dashboard's ``/api/graph`` calls
439440
(engraphis/graphdata.py), so both UIs render identical graphs and share
440441
the same workspace-binding isolation guard."""
441-
selected = [x.strip() for x in layers.split(",") if x.strip()] if layers else None
442+
selected = None if layers is None else [
443+
x.strip() for x in layers.split(",") if x.strip()
444+
]
442445
return svc().graph(
443446
workspace=workspace, limit=limit, layers=selected,
444447
include_code=include_code, repo=repo,

engraphis/read_only_api.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,13 @@ def intent_recall(req: IntentRecallRequest):
8787
)
8888

8989
@app.get("/graph")
90-
def graph(workspace: str, limit: int = 2_000, layers: str = "",
90+
def graph(workspace: str, limit: int = 2_000, layers: Optional[str] = None,
9191
include_code: bool = False, repo: Optional[str] = None):
92-
selected = [value.strip() for value in layers.split(",") if value.strip()]
92+
selected = None if layers is None else [
93+
value.strip() for value in layers.split(",") if value.strip()
94+
]
9395
return run(
94-
svc.graph, workspace=workspace, limit=limit, layers=selected or None,
96+
svc.graph, workspace=workspace, limit=limit, layers=selected,
9597
include_code=include_code, repo=repo, backfill=False,
9698
)
9799

engraphis/routes/v2_api.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1012,7 +1012,9 @@ def graph(workspace: Optional[str] = None, limit: int = 2000,
10121012
service closes that gap.
10131013
"""
10141014
ws = workspace or _default_ws()
1015-
selected = [x.strip() for x in layers.split(",") if x.strip()] if layers else None
1015+
selected = None if layers is None else [
1016+
x.strip() for x in layers.split(",") if x.strip()
1017+
]
10161018
return _run(
10171019
service().graph, workspace=ws, limit=limit, layers=selected,
10181020
include_code=include_code, repo=repo,

engraphis/service.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1642,7 +1642,7 @@ def index_repo(self, *, workspace: str, repo: str, root_path: str,
16421642
ws = self._clean_ws(workspace)
16431643
rp = _clean_name(repo, field="repo")
16441644
root_path = _clean_text(root_path, field="root_path", max_chars=MAX_CONTENT_CHARS)
1645-
wid = self.store.get_or_create_workspace(ws)
1645+
wid = self._get_or_create_workspace(ws)
16461646
rid = self.store.get_or_create_repo(wid, rp)
16471647
langs = None
16481648
if languages:
@@ -2646,11 +2646,13 @@ def graph(self, *, workspace: str, limit: int = 2000,
26462646
(wid, limit)).fetchall()
26472647
entity_rows = [dict(row) for row in ents]
26482648
node_ids = {row["id"] for row in entity_rows}
2649+
selected_graph_layers = None
26492650
selected_layers = None
2650-
if layers:
2651-
selected_layers = {
2652-
_enum(layer, GraphLayer, "layer").value for layer in layers
2653-
}
2651+
if layers is not None:
2652+
selected_graph_layers = [
2653+
_enum(layer, GraphLayer, "layer") for layer in layers
2654+
]
2655+
selected_layers = {layer.value for layer in selected_graph_layers}
26542656
# Nodes are capped at ``limit``; edges need their own cap or a large workspace
26552657
# graph / indexed repo lets the lowest-privilege caller pull an unbounded
26562658
# payload (the SQL fetches are LIMIT-ed too, so server-side work stays
@@ -2663,7 +2665,10 @@ def graph(self, *, workspace: str, limit: int = 2000,
26632665
"layer": edge.layer.value if edge.layer else "semantic",
26642666
}
26652667
for edge in self.store.edges_in_scope(
2666-
SearchFilter(workspace_id=wid), limit=edge_cap
2668+
SearchFilter(
2669+
workspace_id=wid, graph_layers=selected_graph_layers
2670+
),
2671+
limit=edge_cap,
26672672
)
26682673
if edge.src in node_ids and edge.dst in node_ids
26692674
and (
@@ -2739,7 +2744,9 @@ def code_endpoint(value: str, file_hint: str = "") -> Optional[str]:
27392744
})
27402745
return file_nodes.get(file_name)
27412746

2742-
for edge in self.store.list_code_edges(rid, limit=edge_cap):
2747+
for edge in self.store.list_code_edges(
2748+
rid, limit=edge_cap, layers=selected_graph_layers
2749+
):
27432750
if len(edgs) >= edge_cap:
27442751
break
27452752
edge_layer = edge.get("layer") or "entity"

engraphis/static/index.html

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1338,7 +1338,7 @@
13381338
async function syncNow(){const b=document.getElementById('sync-btn');const s=document.getElementById('sync-status');if(b){b.disabled=true;b.textContent='Syncing…'}if(s)s.textContent='Contacting the cloud…';try{const d=await api('/sync/run',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});const su=d.summary||{};toast('Synced — pushed '+(su.exported||0)+', '+(su.added||0)+' new from other devices','ok');await loadSyncStatus()}catch(e){toast('Sync failed: '+e.message,'err');if(b){b.disabled=false;b.textContent='Sync now'}if(s)s.textContent='Sync failed — try again.'}}
13391339

13401340
/* ─── knowledge graph (force-graph + d3-force: compact defaults and selectable layouts) ─── */
1341-
let GRAPH=null, FG=null, GRESIZE=false, GRESIZEFRAME=0, GADJ={}, GCOMPONENTS={}, GHILITE=null, GHOVERSET=null, GLABELRANK={}, GLABELBOXES=[], GDATA_CACHE=null, GACTIVE_DATA=null, GREDRAWFRAME=0, GPERF={large:false,dense:false};
1341+
let GRAPH=null, FG=null, GRESIZE=false, GRESIZEFRAME=0, GADJ={}, GCOMPONENTS={}, GCOMPONENT_LAYOUT=null, GHILITE=null, GHOVERSET=null, GLABELRANK={}, GLABELBOXES=[], GDATA_CACHE=null, GACTIVE_DATA=null, GREDRAWFRAME=0, GPERF={large:false,dense:false};
13421342
const GRAPH_PRESETS={
13431343
original:{label:'Original force',repel:120,link:30,gravity:14,font:13,size:5,linkw:1,labelDensity:40,curve:0,particles:0},
13441344
compact:{label:'Compact clusters',repel:42,link:20,gravity:26,font:12,size:5,linkw:.7,labelDensity:30,curve:.08,particles:0},
@@ -1414,7 +1414,7 @@
14141414
if(count&&data)count.textContent=data.nodes.length.toLocaleString()+' entities · '+data.links.length.toLocaleString()+' relations';
14151415
if(badge)badge.textContent=GPERF.large?'Large graph mode':'Adaptive rendering';
14161416
}
1417-
function graphInvalidateData(){GDATA_CACHE=null;GACTIVE_DATA=null;GHILITE=null;GHOVERSET=null}
1417+
function graphInvalidateData(){GDATA_CACHE=null;GACTIVE_DATA=null;GCOMPONENT_LAYOUT=null;GHILITE=null;GHOVERSET=null}
14181418
async function loadGraph(){
14191419
graphInjectCss();graphInvalidateData();GRAPH=null;
14201420
const empty=document.getElementById('graph-empty'),net=document.getElementById('graph-net'),nodesBox=document.getElementById('graph-entity-list'),edgesBox=document.getElementById('graph-relation-list');
@@ -1428,9 +1428,9 @@
14281428
GRESIZEFRAME=requestAnimationFrame(()=>{GRESIZEFRAME=0;const element=document.getElementById('graph-net');if(FG&&element)FG.width(element.clientWidth).height(element.clientHeight)});
14291429
});
14301430
}
1431-
const layers=Array.from(document.querySelectorAll('#graph-layer-filters input:checked')).map(input=>input.value).join(','),includeCode=document.getElementById('graph-include-code').checked,repo=(document.getElementById('graph-repo-filter').value||'').trim();
1431+
const layerInputs=Array.from(document.querySelectorAll('#graph-layer-filters input')),selectedLayers=layerInputs.filter(input=>input.checked).map(input=>input.value),layerFilter=selectedLayers.length===layerInputs.length?'':'&layers='+encodeURIComponent(selectedLayers.join(',')),includeCode=document.getElementById('graph-include-code').checked,repo=(document.getElementById('graph-repo-filter').value||'').trim();
14321432
try{
1433-
GRAPH=await api('/graph?workspace='+encodeURIComponent(WS||'')+'&layers='+encodeURIComponent(layers)+'&include_code='+(includeCode?'true':'false')+(repo?'&repo='+encodeURIComponent(repo):''));
1433+
GRAPH=await api('/graph?workspace='+encodeURIComponent(WS||'')+layerFilter+'&include_code='+(includeCode?'true':'false')+(repo?'&repo='+encodeURIComponent(repo):''));
14341434
renderGraphSide();graphRender();
14351435
}catch(error){
14361436
empty.style.display='flex';empty.textContent='Graph failed: '+error.message;graphSetLayoutStatus('Load failed',false);
@@ -1472,6 +1472,11 @@
14721472
ids.forEach(id=>{GCOMPONENTS[id]={index,size:ids.length,x,y}});
14731473
});
14741474
}
1475+
function graphRefreshComponentCenters(nodes,force=false){
1476+
const layout=window.GSET.mode+'|'+window.GSET.link;
1477+
if(!force&&GCOMPONENT_LAYOUT===layout)return;
1478+
graphIndexComponents(nodes);GCOMPONENT_LAYOUT=layout;
1479+
}
14751480
function graphAlpha(color,alpha){
14761481
const hex=/^#([0-9a-f]{6})$/i.exec(color||'');
14771482
if(hex){const value=parseInt(hex[1],16);return `rgba(${value>>16},${(value>>8)&255},${value&255},${alpha})`}
@@ -1523,9 +1528,9 @@
15231528
GPERF={large:data.nodes.length>600||data.links.length>2400,dense:data.links.length>1500};
15241529
if(dataChanged){
15251530
buildAdj(data.links);GLABELRANK={};data.nodes.forEach(node=>{GLABELRANK[node.id]=node.rank});GACTIVE_DATA=data;
1526-
graphIndexComponents(data.nodes);
15271531
}
1528-
graphSetHighlight(null);
1532+
graphRefreshComponentCenters(data.nodes,dataChanged);
1533+
if(dataChanged)graphSetHighlight(null);
15291534
if(!data.nodes.length){
15301535
empty.style.display='flex';
15311536
empty.textContent=GRAPH.nodes.length?('No connected entities — untick "Hide unconnected" to see all '+GRAPH.nodes.length+'.'):'No entities in this workspace yet.';
@@ -1579,7 +1584,9 @@
15791584
}else{FG.linkCanvasObjectMode(()=>undefined)}
15801585
FG.onRenderFramePost((ctx,scale)=>{
15811586
if(!(settings.font>0))return;
1582-
const nodes=GACTIVE_DATA.nodes,focus=GHOVERSET&&GHOVERSET.size>1,cap=Math.max(1,Math.round((settings.labelDensity||40)*Math.max(.32,scale-.85))),fontSize=settings.font/scale,boxes=GLABELBOXES,candidateLimit=Math.min(nodes.length,Math.max(72,cap*10));
1587+
const nodes=(GACTIVE_DATA&&GACTIVE_DATA.nodes)||[];
1588+
if(!nodes.length)return;
1589+
const focus=GHOVERSET&&GHOVERSET.size>1,cap=Math.max(1,Math.round((settings.labelDensity||40)*Math.max(.32,scale-.85))),fontSize=settings.font/scale,boxes=GLABELBOXES,candidateLimit=Math.min(nodes.length,Math.max(72,cap*10));
15831590
boxes.length=0;
15841591
ctx.textAlign='center';ctx.textBaseline='top';ctx.lineJoin='round';ctx.font=fontSize+'px -apple-system,Segoe UI,sans-serif';ctx.lineWidth=3/scale;ctx.strokeStyle=window.GCOL.panel;ctx.fillStyle=window.GCOL.label;
15851592
let drawn=0;
@@ -1615,6 +1622,7 @@
16151622
if(!FG)return;
16161623
const layout=key==='repel'||key==='link'||key==='gravity'||key==='size';
16171624
if(key==='size')graphRefreshNodeMetrics();
1625+
if(key==='link'&&GACTIVE_DATA)graphRefreshComponentCenters(GACTIVE_DATA.nodes);
16181626
if(layout)graphApplyForces();
16191627
if(key==='linkw'){FG.linkWidth(FG.linkWidth());FG.linkColor(FG.linkColor())}else graphRedraw();
16201628
if(layout&&!prefersReducedMotion()){graphSetLayoutStatus('Updating layout',true);FG.d3ReheatSimulation()}

tests/test_core_store.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,13 @@ def test_code_listing_helpers_honor_limit(store):
222222
relation="calls", file="mod.py", line=i + 1)
223223
assert len(store.list_code_edges("repo_x")) == 4
224224
assert len(store.list_code_edges("repo_x", limit=3)) == 3
225+
assert len(store.list_code_edges(
226+
"repo_x", layers=[GraphLayer.ENTITY], limit=3
227+
)) == 3
228+
assert store.list_code_edges(
229+
"repo_x", layers=[GraphLayer.CAUSAL], limit=3
230+
) == []
231+
assert store.list_code_edges("repo_x", layers=[], limit=3) == []
225232
# list_code_files was the one sibling with no SQL limit, so engine.export_code_graph
226233
# had to fetch the whole repo and slice it in Python.
227234
for i in range(5):

tests/test_dashboard_v2.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,33 @@ def handle_data(self, data):
209209
{"value": "shared", "checked": False},
210210
]
211211

212+
213+
def test_same_graph_data_refreshes_component_centers_after_layout_changes(monkeypatch,
214+
tmp_path):
215+
with _client(monkeypatch, tmp_path) as c:
216+
response = c.get("/")
217+
assert response.status_code == 200
218+
219+
html = response.text
220+
refresh = html[html.index("function graphRefreshComponentCenters"):
221+
html.index("function graphAlpha")]
222+
render = html[html.index("function graphRender"):
223+
html.index("function graphSet(key,value)")]
224+
graph_set = html[html.index("function graphSet(key,value)"):
225+
html.index("function graphApplyPreset")]
226+
preset = html[html.index("function graphApplyPreset"):
227+
html.index("function graphToggleLabels")]
228+
229+
assert "window.GSET.mode+'|'+window.GSET.link" in refresh
230+
assert "if(!force&&GCOMPONENT_LAYOUT===layout)return" in refresh
231+
assert "graphIndexComponents(nodes)" in refresh
232+
assert "graphRefreshComponentCenters(data.nodes,dataChanged)" in render
233+
assert "if(dataChanged)FG.graphData(data)" in render
234+
assert "if(dataChanged)graphSetHighlight(null)" in render
235+
assert ("if(key==='link'&&GACTIVE_DATA)"
236+
"graphRefreshComponentCenters(GACTIVE_DATA.nodes)" in graph_set)
237+
assert "if(FG)graphRender(true,true)" in preset
238+
212239
def test_unconfigured_remote_api_is_refused_but_loopback_is_allowed(monkeypatch, tmp_path):
213240
remote = ("203.0.113.8", 50000)
214241
with _client(monkeypatch, tmp_path, client=remote) as c:
@@ -541,13 +568,40 @@ def test_viewer_role_denied_on_governance_and_admin_routes(monkeypatch, tmp_path
541568
assert "admin-folder" not in names
542569

543570

544-
def test_graph_endpoint_shape(monkeypatch, tmp_path):
571+
def test_graph_endpoint_preserves_omitted_and_explicit_empty_layers(monkeypatch, tmp_path):
545572
with _client(monkeypatch, tmp_path) as c:
546-
g = c.get("/api/graph?workspace=demo").json()
547-
assert set(g) >= {"nodes", "edges", "types", "top", "stats"}
548-
assert set(g["stats"]) >= {"entities", "edges", "connected", "isolated"}
549-
ids = {n["id"] for n in g["nodes"]}
550-
assert all(e["from"] in ids and e["to"] in ids for e in g["edges"])
573+
from engraphis.routes import v2_api
574+
575+
svc = v2_api.service()
576+
wid = svc.store.get_or_create_workspace("demo")
577+
conn = svc.store.conn
578+
conn.execute(
579+
"INSERT INTO entities(id, workspace_id, repo_id, name, etype, created_at) "
580+
"VALUES (?,?,?,?,?,0)",
581+
("layer-filter-a", wid, None, "Layer filter A", "concept"),
582+
)
583+
conn.execute(
584+
"INSERT INTO entities(id, workspace_id, repo_id, name, etype, created_at) "
585+
"VALUES (?,?,?,?,?,0)",
586+
("layer-filter-b", wid, None, "Layer filter B", "concept"),
587+
)
588+
conn.execute(
589+
"INSERT INTO edges(id, workspace_id, repo_id, src, dst, relation, layer) "
590+
"VALUES (?,?,?,?,?,?,?)",
591+
("layer-filter-edge", wid, None, "layer-filter-a", "layer-filter-b",
592+
"related_to", "semantic"),
593+
)
594+
conn.commit()
595+
596+
omitted = c.get("/api/graph", params={"workspace": "demo"}).json()
597+
explicit_empty = c.get(
598+
"/api/graph?workspace=demo&layers="
599+
).json()
600+
601+
assert set(omitted) >= {"nodes", "edges", "types", "top", "stats"}
602+
assert set(omitted["stats"]) >= {"entities", "edges", "connected", "isolated"}
603+
assert any(edge["label"] == "related_to" for edge in omitted["edges"])
604+
assert explicit_empty["edges"] == []
551605

552606

553607
def test_team_seat_limit_enforcement(monkeypatch, tmp_path):

tests/test_inspector.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def test_graph_endpoint_serves_the_same_data_as_the_dashboard():
9494
{"from": "e1", "to": "e2", "label": "works_at", "layer": "entity"}
9595
]
9696
assert g["stats"] == {"entities": 2, "edges": 1, "connected": 2, "isolated": 0}
97+
assert c.get("/api/graph?workspace=acme&layers=").json()["edges"] == []
9798

9899

99100
def test_graph_endpoint_rejects_workspace_outside_the_binding():

0 commit comments

Comments
 (0)