Skip to content

Commit 35ef8b7

Browse files
committed
fix(relations): only set targetProjectSlug for genuine cross-project edges
The previous commit always populated targetProjectSlug from the target's project_id, but for workspace projects the source-side StoreManager is bound to the synthetic workspace-root project (e.g. slug 'backend'), and that root is not exposed as a navigable project in the multi-config — only its real members like 'api-gateway' and 'catalog-service' are. So workspace-shared knowledge → knowledge edges were emitting targetProjectSlug='backend', the UI tried to navigate to /ui/backend/knowledge/X, and the REST layer rejected it with "Project 'backend' not found". Fix: skip resolution when targetProjectId equals the current scoped project. The UI then falls back to whichever real project is already in the URL. Cross-project resolution only kicks in when the target genuinely lives in a different project (workspace knowledge → code/ docs/files in a specific subproject). Verified end-to-end against the real demo workspace database: Note 25 (workspace knowledge): out implemented_in → code "rate-limiter.ts" project: api-gateway out documented_in → docs "Rate Limiting" project: api-gateway in relates_to → knowledge "API Versioning Strategy" project: (current) in implements → tasks "..." project: (current) Note 28: out implemented_in → code "search-service.ts" project: catalog-service out documented_in → docs "Search Algorithm" project: catalog-service out depends_on → knowledge "..." project: (current)
1 parent 163db54 commit 35ef8b7

2 files changed

Lines changed: 26 additions & 18 deletions

File tree

src/lib/store-manager.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -584,10 +584,18 @@ export class StoreManager {
584584
for (const [g, ids] of idsByGraph) {
585585
titles.set(g, this.scoped.resolveTitles(g, ids));
586586
}
587-
// Resolve project ids → slugs (batched via small in-memory cache).
587+
// Resolve project ids → slugs only when the target lives in a *different*
588+
// project than the current scoped store. Same-project (including workspace-
589+
// shared graphs where every node has project_id = workspace root) leaves
590+
// targetProjectSlug undefined so the UI falls back to whatever project is
591+
// already in the URL — that's important because the synthetic workspace
592+
// root project (e.g. slug 'backend') is not a navigable project in the
593+
// multi-config: only its real members like 'api-gateway' / 'catalog-service'
594+
// are exposed.
595+
const currentProjectId = this.scoped.projectId;
588596
const projectSlugCache = new Map<number, string | undefined>();
589-
const resolveProjectSlug = (id: number | undefined): string | undefined => {
590-
if (id === undefined) return undefined;
597+
const resolveTargetProjectSlug = (id: number | undefined): string | undefined => {
598+
if (id === undefined || id === currentProjectId) return undefined;
591599
if (projectSlugCache.has(id)) return projectSlugCache.get(id);
592600
const slug = this.store.projects.get(id)?.slug;
593601
projectSlugCache.set(id, slug);
@@ -597,7 +605,7 @@ export class StoreManager {
597605
...v.edge,
598606
targetGraph: v.targetGraph,
599607
targetId: v.targetId,
600-
targetProjectSlug: resolveProjectSlug(v.targetProjectId),
608+
targetProjectSlug: resolveTargetProjectSlug(v.targetProjectId),
601609
title: titles.get(v.targetGraph)?.get(v.targetId) ?? String(v.targetId),
602610
direction: v.direction,
603611
}));

src/tests/store/store-manager.test.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -605,27 +605,27 @@ describe('StoreManager', () => {
605605
expect(titles.get(tagEdge!.fromId)).toBe('gamma');
606606
});
607607

608-
it('populates targetProjectSlug from edge fromProjectId/toProjectId', async () => {
609-
// The current test setup has a single project, so we synthesize the
610-
// cross-project case by injecting raw Edge objects with explicit
611-
// fromProjectId/toProjectId. This mirrors how the workspace scenario
612-
// works in real life: shared knowledge → project-scoped code/docs/files.
613-
const note = await manager.createNote({ title: 'Cross-project source', content: '' });
608+
it('leaves targetProjectSlug undefined when target lives in the current project', async () => {
609+
// Same-project edges should not surface a targetProjectSlug. The UI
610+
// falls back to the project already in the URL, so workspace-shared
611+
// knowledge (where every node lives under the synthetic workspace-root
612+
// project) keeps navigating through whichever real project the user is
613+
// currently viewing.
614+
const note = await manager.createNote({ title: 'Source', content: '' });
614615
const fakeOutgoing = {
615-
fromGraph: 'knowledge' as const, fromId: note.id, fromProjectId: 1,
616-
toGraph: 'code' as const, toId: 999, toProjectId: 1,
617-
kind: 'implemented_in',
616+
fromGraph: 'knowledge' as const, fromId: note.id, fromProjectId: manager.projectId,
617+
toGraph: 'knowledge' as const, toId: 999, toProjectId: manager.projectId,
618+
kind: 'relates_to',
618619
};
619620
const fakeIncoming = {
620-
fromGraph: 'tasks' as const, fromId: 888, fromProjectId: 1,
621-
toGraph: 'knowledge' as const, toId: note.id, toProjectId: 1,
621+
fromGraph: 'tasks' as const, fromId: 888, fromProjectId: manager.projectId,
622+
toGraph: 'knowledge' as const, toId: note.id, toProjectId: manager.projectId,
622623
kind: 'documents',
623624
};
624625
const enriched = manager.enrichRelations('knowledge', note.id, [fakeOutgoing, fakeIncoming]);
625626
expect(enriched).toHaveLength(2);
626-
// Project id 1 was created in beforeEach with slug 'test'.
627-
expect(enriched[0].targetProjectSlug).toBe('test');
628-
expect(enriched[1].targetProjectSlug).toBe('test');
627+
expect(enriched[0].targetProjectSlug).toBeUndefined();
628+
expect(enriched[1].targetProjectSlug).toBeUndefined();
629629
});
630630

631631
it('createEdge stores correct to_project_id for cross-project targets', async () => {

0 commit comments

Comments
 (0)