Skip to content

Commit 03f25e5

Browse files
feat(wikiserver): use DB fallback for wiki APIs
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 49b37bd commit 03f25e5

3 files changed

Lines changed: 498 additions & 20 deletions

File tree

internal/server/serve.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ func RunStreamableHTTP(rt *core.Runtime, srv *mcpgo.MCPServer, cfg Config, cache
122122
RagIndexDir: cfg.RagIndexDir,
123123
NamespaceRoot: cfg.NamespaceRoot,
124124
DB: rt.DB,
125+
SearchBackend: rt.SearchBackend,
125126
Logger: rt.Logger,
126127
})
127128
if err != nil {

internal/wikiserver/server.go

Lines changed: 111 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
package wikiserver
33

44
import (
5+
"context"
56
"encoding/json"
67
"errors"
78
"fmt"
@@ -13,6 +14,7 @@ import (
1314
"sort"
1415
"strconv"
1516
"strings"
17+
"time"
1618

1719
"gorm.io/gorm"
1820

@@ -21,6 +23,9 @@ import (
2123
"github.com/tae2089/code-context-graph/internal/model"
2224
nsfs "github.com/tae2089/code-context-graph/internal/namespacefs"
2325
"github.com/tae2089/code-context-graph/internal/ragindex"
26+
"github.com/tae2089/code-context-graph/internal/retrieval"
27+
storesearch "github.com/tae2089/code-context-graph/internal/store/search"
28+
"github.com/tae2089/code-context-graph/internal/wikiindex"
2429
"github.com/tae2089/trace"
2530
)
2631

@@ -33,6 +38,7 @@ type Config struct {
3338
RagIndexDir string
3439
NamespaceRoot string
3540
DB *gorm.DB
41+
SearchBackend storesearch.Backend
3642
Logger *slog.Logger
3743
}
3844

@@ -43,6 +49,7 @@ type Server struct {
4349
ragIndexDir string
4450
namespaceRoot string
4551
db *gorm.DB
52+
searchBackend storesearch.Backend
4653
logger *slog.Logger
4754
}
4855

@@ -76,6 +83,7 @@ func New(cfg Config) (*Server, error) {
7683
ragIndexDir: ragDir,
7784
namespaceRoot: nsRoot,
7885
db: cfg.DB,
86+
searchBackend: cfg.SearchBackend,
7987
logger: logger,
8088
}, nil
8189
}
@@ -176,13 +184,13 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
176184
writeError(w, http.StatusBadRequest, err.Error(), nil)
177185
return
178186
}
179-
idx, err := s.loadIndex(ns)
187+
root, builtAt, _, err := s.loadWikiTree(r.Context(), ns)
180188
if err != nil {
181189
writeError(w, statusForReadErr(err), "load wiki-index", err)
182190
return
183191
}
184-
root := ragindex.PruneTree(idx.Root, depth)
185-
writeJSON(w, http.StatusOK, map[string]any{"namespace": ns, "built_at": idx.BuiltAt, "root": root})
192+
root = ragindex.PruneTree(root, depth)
193+
writeJSON(w, http.StatusOK, map[string]any{"namespace": ns, "built_at": builtAt, "root": root})
186194
}
187195

188196
// @intent search Wiki tree labels and summaries for the active namespace.
@@ -204,12 +212,12 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
204212
writeError(w, http.StatusBadRequest, err.Error(), nil)
205213
return
206214
}
207-
idx, err := s.loadIndex(ns)
215+
root, _, _, err := s.loadWikiTree(r.Context(), ns)
208216
if err != nil {
209217
writeError(w, statusForReadErr(err), "load wiki-index", err)
210218
return
211219
}
212-
results := ragindex.Search(idx.Root, query, limit)
220+
results := ragindex.Search(root, query, limit)
213221
if results == nil {
214222
results = []ragindex.SearchResult{}
215223
}
@@ -242,6 +250,15 @@ func (s *Server) handleRetrieve(w http.ResponseWriter, r *http.Request) {
242250
}
243251
idx, err := s.loadRAGIndex(ns)
244252
if err != nil {
253+
if errors.Is(err, fs.ErrNotExist) && s.db != nil {
254+
response, fallbackErr := s.retrieveFromDB(r.Context(), ns, query, limit, contentLimit)
255+
if fallbackErr != nil {
256+
writeError(w, http.StatusInternalServerError, "retrieve from DB", fallbackErr)
257+
return
258+
}
259+
writeJSON(w, http.StatusOK, map[string]any{"namespace": ns, "results": response.Results})
260+
return
261+
}
245262
writeError(w, statusForReadErr(err), "load doc-index", err)
246263
return
247264
}
@@ -267,6 +284,33 @@ func (s *Server) handleRetrieve(w http.ResponseWriter, r *http.Request) {
267284
writeJSON(w, http.StatusOK, map[string]any{"namespace": ns, "results": results})
268285
}
269286

287+
// @intent provide Wiki retrieve results from graph DB data when doc-index.json has not been generated yet.
288+
// @domainRule JSON doc-index remains preferred; this fallback only runs for missing index files.
289+
// @sideEffect queries the graph DB and may read generated Markdown docs.
290+
func (s *Server) retrieveFromDB(ctx context.Context, namespace, query string, limit, contentLimit int) (retrieval.Response, error) {
291+
service := retrieval.Service{DB: s.db, SearchBackend: s.searchBackend}
292+
return service.FromDB(ctx, namespace, query, limit, contentLimit, func(_ context.Context, ns, docPath string, limit int) (string, bool, error) {
293+
content, _, err := s.readDBFallbackDoc(ns, docPath)
294+
if err != nil {
295+
return "", false, err
296+
}
297+
if len(content) <= limit {
298+
return content, false, nil
299+
}
300+
return content[:limit], true, nil
301+
})
302+
}
303+
304+
// @intent read DB fallback document content without crossing from a named namespace into shared/global docs roots.
305+
// @domainRule named namespace retrieve fallback must omit content rather than read another namespace's generated Markdown.
306+
// @sideEffect reads a generated Markdown file from disk when it exists under the namespace root.
307+
func (s *Server) readDBFallbackDoc(namespace, docPath string) (string, string, error) {
308+
if ctxns.Normalize(namespace) == ctxns.DefaultNamespace {
309+
return s.readDoc(namespace, docPath)
310+
}
311+
return s.readDocUnderRoot(filepath.Join(s.namespaceRoot, namespace), docPath)
312+
}
313+
270314
// @intent return a bounded namespace graph for the browser force-directed graph viewer.
271315
func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) {
272316
if !requireMethod(w, r, http.MethodGet) {
@@ -369,10 +413,15 @@ func (s *Server) handleDoc(w http.ResponseWriter, r *http.Request) {
369413
writeError(w, http.StatusBadRequest, "path must not be empty", nil)
370414
return
371415
}
372-
content, resolved, err := s.readDoc(ns, docPath)
416+
_, indexErr := s.loadIndex(ns)
417+
readDoc := s.readDoc
418+
if errors.Is(indexErr, fs.ErrNotExist) && s.db != nil {
419+
readDoc = s.readDBFallbackDoc
420+
}
421+
content, resolved, err := readDoc(ns, docPath)
373422
if err != nil {
374-
if idx, indexErr := s.loadIndex(ns); indexErr == nil {
375-
if node := findDocPath(idx.Root, docPath); node != nil {
423+
if root, _, _, indexErr := s.loadWikiTree(r.Context(), ns); indexErr == nil {
424+
if node := findDocPath(root, docPath); node != nil {
376425
writeJSON(w, http.StatusOK, map[string]any{
377426
"namespace": ns,
378427
"path": docPath,
@@ -414,10 +463,10 @@ func (s *Server) handleRef(w http.ResponseWriter, r *http.Request) {
414463
writeError(w, http.StatusBadRequest, err.Error(), nil)
415464
return
416465
}
417-
idx, indexErr := s.loadIndex(ref.Namespace)
466+
root, _, _, indexErr := s.loadWikiTree(r.Context(), ref.Namespace)
418467
var treeNode *ragindex.TreeNode
419468
if indexErr == nil {
420-
treeNode = findRefTreeNode(idx.Root, ref)
469+
treeNode = findRefTreeNode(root, ref)
421470
}
422471
graphNode, graphErr := s.findRefGraphNode(r, ref)
423472
if treeNode == nil && graphNode == nil {
@@ -458,11 +507,15 @@ func (s *Server) handleContext(w http.ResponseWriter, r *http.Request) {
458507
writeError(w, http.StatusBadRequest, "paths must contain <= 20 items", nil)
459508
return
460509
}
461-
idx, err := s.loadIndex(ns)
510+
root, _, fromDB, err := s.loadWikiTree(r.Context(), ns)
462511
if err != nil {
463512
writeError(w, statusForReadErr(err), "load wiki-index", err)
464513
return
465514
}
515+
readDoc := s.readDoc
516+
if fromDB {
517+
readDoc = s.readDBFallbackDoc
518+
}
466519
sections := make([]string, 0, len(req.Paths))
467520
items := make([]contextItem, 0, len(req.Paths))
468521
for _, rawPath := range req.Paths {
@@ -471,14 +524,14 @@ func (s *Server) handleContext(w http.ResponseWriter, r *http.Request) {
471524
continue
472525
}
473526
item := contextItem{Path: docPath}
474-
if content, _, err := s.readDoc(ns, docPath); err == nil {
527+
if content, _, err := readDoc(ns, docPath); err == nil {
475528
item.Found = true
476529
item.Markdown = fmt.Sprintf("## %s\n\n%s", docPath, strings.TrimSpace(content))
477530
sections = append(sections, item.Markdown)
478-
} else if node := findDocPath(idx.Root, docPath); node != nil {
531+
} else if node := findDocPath(root, docPath); node != nil {
479532
item.Found = true
480533
item.Label = node.Label
481-
item.Markdown = fmt.Sprintf("## %s\n\n%s", node.Label, strings.TrimSpace(node.Summary))
534+
item.Markdown = fmt.Sprintf("## %s\n\n%s", node.Label, strings.TrimSpace(nodeMarkdown(node)))
482535
sections = append(sections, item.Markdown)
483536
} else {
484537
item.Error = "not found"
@@ -625,6 +678,23 @@ func (s *Server) loadIndex(namespace string) (*ragindex.Index, error) {
625678
return ragindex.LoadIndex(s.indexPath(namespace))
626679
}
627680

681+
// @intent load a Wiki tree from JSON or DB fallback while preserving built_at response metadata.
682+
// @domainRule generated wiki-index.json remains authoritative; DB fallback only runs when the file is absent and DB is configured.
683+
func (s *Server) loadWikiTree(ctx context.Context, namespace string) (*ragindex.TreeNode, time.Time, bool, error) {
684+
idx, err := s.loadIndex(namespace)
685+
if err == nil {
686+
return idx.Root, idx.BuiltAt, false, nil
687+
}
688+
if !errors.Is(err, fs.ErrNotExist) || s.db == nil {
689+
return nil, time.Time{}, false, err
690+
}
691+
root, _, _, buildErr := (&wikiindex.Builder{DB: s.db, OutDir: "docs", Namespace: namespace}).BuildTree(ctx)
692+
if buildErr != nil {
693+
return nil, time.Time{}, false, buildErr
694+
}
695+
return root, time.Now().UTC(), true, nil
696+
}
697+
628698
// @intent load the namespace-specific doc-index used by PageIndex retrieval.
629699
func (s *Server) loadRAGIndex(namespace string) (*ragindex.Index, error) {
630700
if ctxns.Normalize(namespace) == ctxns.DefaultNamespace {
@@ -672,6 +742,24 @@ func (s *Server) readDoc(namespace, docPath string) (string, string, error) {
672742
if err != nil {
673743
return "", "", err
674744
}
745+
return readDocFile(resolved)
746+
}
747+
748+
// @intent read a generated doc path from one explicit root with the standard Wiki size limit.
749+
func (s *Server) readDocUnderRoot(root, docPath string) (string, string, error) {
750+
clean := filepath.Clean(docPath)
751+
if clean == "." || clean == ".." || filepath.IsAbs(clean) || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) {
752+
return "", "", fmt.Errorf("invalid path: path traversal not allowed")
753+
}
754+
resolved, err := safePath(root, clean)
755+
if err != nil {
756+
return "", "", err
757+
}
758+
return readDocFile(resolved)
759+
}
760+
761+
// @intent enforce generated doc size limits and read the resolved Markdown file.
762+
func readDocFile(resolved string) (string, string, error) {
675763
info, err := os.Stat(resolved)
676764
if err != nil {
677765
return "", "", err
@@ -850,6 +938,9 @@ func refTargetFromMatches(treeNode *ragindex.TreeNode, graphNode *graphNode) ref
850938
}
851939
target.GraphNodeID = graphNode.ID
852940
}
941+
if target.DocPath == "" && target.Details != nil && strings.TrimSpace(target.Details.FilePath) != "" {
942+
target.DocPath = docPathForSource(target.Details.FilePath)
943+
}
853944
return target
854945
}
855946

@@ -894,7 +985,11 @@ func nodeMarkdown(node *ragindex.TreeNode) string {
894985
if len(node.Children) > 0 {
895986
var children []string
896987
for _, child := range node.Children {
897-
children = append(children, "- "+child.Label)
988+
line := "- " + child.Label
989+
if strings.TrimSpace(child.Summary) != "" {
990+
line += ": " + strings.TrimSpace(child.Summary)
991+
}
992+
children = append(children, line)
898993
}
899994
parts = append(parts, strings.Join(children, "\n"))
900995
}
@@ -938,7 +1033,7 @@ func graphEdgeKindsParam(r *http.Request) ([]model.EdgeKind, error) {
9381033
}
9391034
var out []model.EdgeKind
9401035
seen := map[model.EdgeKind]struct{}{}
941-
for _, item := range strings.Split(raw, ",") {
1036+
for item := range strings.SplitSeq(raw, ",") {
9421037
kind := model.EdgeKind(strings.TrimSpace(item))
9431038
if kind == "" {
9441039
continue

0 commit comments

Comments
 (0)