feat(graph): wire graphsync into MCP write/ingest/tunnel handlers
Commit 2 of Track A. Service stays a no-op until BRAIN_GRAPH_ENABLED= true; flipping it on creates the schema (idempotent), starts indexing every successful write, and optionally backfills the existing brain dir. - internal/graphsync: best-effort wrapper around graph.Extract + graphstore. IndexDoc reads docPath under brainDir, parses, upserts entity + replaces edges. BackfillFromBrainDir walks wiki/ + knowledge/. Both are no-ops on nil store so callers wire unconditionally. - mcp.Server gains WithGraph builder + graphsync.Store field. brain_write, brain_ingest, brain_ingest_raw, brain_tunnel call indexInGraph after success — failures slog.Warn but never propagate (graph is augmentation, not correctness). - cmd/server gates the wiring on BRAIN_GRAPH_ENABLED=true (default off so first rollout doesn't surprise). BRAIN_GRAPH_BACKFILL=true triggers a one-shot walk of the brain dir on boot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
134
ingestion/internal/graphsync/graphsync_test.go
Normal file
134
ingestion/internal/graphsync/graphsync_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package graphsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graph"
|
||||
)
|
||||
|
||||
// fakeStore captures the calls IndexDoc / BackfillFromBrainDir made.
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
upserts []graph.Entity
|
||||
replaces map[string][]graph.Edge
|
||||
deletes []string
|
||||
failOn string // upsert fails when entity slug == failOn
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{replaces: make(map[string][]graph.Edge)}
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpsertEntity(_ context.Context, e graph.Entity) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.failOn != "" && e.Slug == f.failOn {
|
||||
return errors.New("synthetic failure")
|
||||
}
|
||||
f.upserts = append(f.upserts, e)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ReplaceEdgesForDoc(_ context.Context, docPath string, edges []graph.Edge) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.replaces[docPath] = append([]graph.Edge(nil), edges...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteByDoc(_ context.Context, docPath string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.deletes = append(f.deletes, docPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeBrain(t *testing.T, brainDir, relPath, body string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(brainDir, filepath.FromSlash(relPath))
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||
require.NoError(t, os.WriteFile(full, []byte(body), 0o644))
|
||||
}
|
||||
|
||||
func TestIndexDoc_UpsertsEntityAndEdges(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
writeBrain(t, tmp, "wiki/concepts/foo.md", `---
|
||||
title: Foo
|
||||
---
|
||||
# Foo
|
||||
Linking to [[bar]] and [[baz|Baz]].
|
||||
`)
|
||||
fs := newFakeStore()
|
||||
require.NoError(t, IndexDoc(context.Background(), fs, tmp, "wiki/concepts/foo.md"))
|
||||
|
||||
require.Len(t, fs.upserts, 1)
|
||||
assert.Equal(t, "foo", fs.upserts[0].Slug)
|
||||
assert.Equal(t, "concept", fs.upserts[0].Type)
|
||||
|
||||
edges := fs.replaces["wiki/concepts/foo.md"]
|
||||
require.Len(t, edges, 2)
|
||||
assert.Equal(t, "bar", edges[0].DstSlug)
|
||||
assert.Equal(t, "baz", edges[1].DstSlug)
|
||||
}
|
||||
|
||||
func TestIndexDoc_NoopOnNilStore(t *testing.T) {
|
||||
require.NoError(t, IndexDoc(context.Background(), nil, "anywhere", "foo.md"))
|
||||
}
|
||||
|
||||
func TestIndexDoc_NoopOnEmptyRelPath(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
require.NoError(t, IndexDoc(context.Background(), fs, "anywhere", ""))
|
||||
assert.Empty(t, fs.upserts)
|
||||
}
|
||||
|
||||
func TestIndexDoc_ErrorsOnMissingFile(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
err := IndexDoc(context.Background(), fs, t.TempDir(), "wiki/nope.md")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIndexDoc_SurfacesStoreFailure(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
writeBrain(t, tmp, "wiki/concepts/boom.md", "# Boom\n")
|
||||
fs := newFakeStore()
|
||||
fs.failOn = "boom"
|
||||
err := IndexDoc(context.Background(), fs, tmp, "wiki/concepts/boom.md")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestBackfillFromBrainDir_WalksWikiAndKnowledge(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
writeBrain(t, tmp, "wiki/concepts/foo.md", "# Foo\n[[bar]]\n")
|
||||
writeBrain(t, tmp, "wiki/entities/bar.md", "# Bar\n")
|
||||
writeBrain(t, tmp, "knowledge/legacy.md", "# Legacy [[foo]]\n")
|
||||
// non-markdown file should be skipped
|
||||
writeBrain(t, tmp, "wiki/concepts/skip.txt", "ignore me")
|
||||
|
||||
fs := newFakeStore()
|
||||
n, err := BackfillFromBrainDir(context.Background(), fs, tmp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3, n)
|
||||
assert.Len(t, fs.upserts, 3)
|
||||
}
|
||||
|
||||
func TestBackfillFromBrainDir_TolerantOfMissingDirs(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
fs := newFakeStore()
|
||||
n, err := BackfillFromBrainDir(context.Background(), fs, tmp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, n)
|
||||
}
|
||||
|
||||
func TestBackfillFromBrainDir_NilStoreNoop(t *testing.T) {
|
||||
n, err := BackfillFromBrainDir(context.Background(), nil, t.TempDir())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, n)
|
||||
}
|
||||
Reference in New Issue
Block a user