feat: agent wiki — articles, revisions, backlinks, FTS search, REST API
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
53c8ba5bb0
commit
eea6176ca9
@@ -51,6 +51,7 @@ import (
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
"github.com/synapbus/synapbus/internal/trust"
|
||||
"github.com/synapbus/synapbus/internal/web"
|
||||
"github.com/synapbus/synapbus/internal/wiki"
|
||||
"github.com/synapbus/synapbus/internal/webhooks"
|
||||
)
|
||||
|
||||
@@ -492,7 +493,9 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
actionIndex := actions.NewIndex(actionRegistry.List())
|
||||
|
||||
// Create MCP server (4 hybrid tools: my_status, send_message, search, execute)
|
||||
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attachmentService, searchService, reactionService, trustService, con, jsPool, actionRegistry, actionIndex, db.DB)
|
||||
wikiService := wiki.NewService(db.DB)
|
||||
|
||||
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attachmentService, searchService, reactionService, trustService, wikiService, con, jsPool, actionRegistry, actionIndex, db.DB)
|
||||
|
||||
// Set up SQL query executor for agents (uses read pool if available)
|
||||
queryDB := db.QueryDB()
|
||||
@@ -664,6 +667,7 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
ReactorStore: reactorStore,
|
||||
ReactorEngine: reactorEngine,
|
||||
BaseURL: baseURL,
|
||||
WikiService: wikiService,
|
||||
})
|
||||
r.Mount("/", apiRouter)
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ type Registry struct {
|
||||
ordered []Action // maintains insertion order
|
||||
}
|
||||
|
||||
// NewRegistry creates a registry pre-populated with all 28 agent-callable actions.
|
||||
// NewRegistry creates a registry pre-populated with all 33 agent-callable actions.
|
||||
func NewRegistry() *Registry {
|
||||
r := &Registry{
|
||||
actions: make(map[string]Action, 28),
|
||||
actions: make(map[string]Action, 33),
|
||||
}
|
||||
for _, a := range allActions() {
|
||||
r.actions[a.Name] = a
|
||||
@@ -42,7 +42,7 @@ func (r *Registry) ListByCategory(category string) []Action {
|
||||
return out
|
||||
}
|
||||
|
||||
// allActions returns the canonical list of all 28 agent-callable actions.
|
||||
// allActions returns the canonical list of all 33 agent-callable actions.
|
||||
func allActions() []Action {
|
||||
return []Action{
|
||||
// ── Messaging (7 actions) ──────────────────────────────────────
|
||||
@@ -599,5 +599,78 @@ func allActions() []Action {
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── Wiki (5 actions) ──────────────────────────────────────
|
||||
{
|
||||
Name: "create_article",
|
||||
Category: "wiki",
|
||||
Description: "Create a new wiki article. Articles are living markdown documents that agents maintain collaboratively. Use [[slug]] syntax in the body to create backlinks to other articles.",
|
||||
Params: []Param{
|
||||
{Name: "slug", Type: "string", Required: true, Description: "URL-friendly identifier (lowercase, hyphens, 2-100 chars). e.g. 'mcp-gateway-competitors'"},
|
||||
{Name: "title", Type: "string", Required: true, Description: "Human-readable article title"},
|
||||
{Name: "body", Type: "string", Required: true, Description: "Markdown article body. Use [[other-slug]] or [[other-slug|Display Text]] for wiki links"},
|
||||
},
|
||||
Returns: "Created article with id, slug, title, revision, created_at",
|
||||
Examples: []Example{{
|
||||
Description: "Create an article about MCP security",
|
||||
Code: `call("create_article", {"slug": "mcp-security-landscape", "title": "MCP Security Landscape", "body": "# MCP Security\n\nRelated: [[mcp-gateway-competitors]] and [[a2a-protocols]]"})`,
|
||||
}},
|
||||
},
|
||||
{
|
||||
Name: "get_article",
|
||||
Category: "wiki",
|
||||
Description: "Get a wiki article by its slug. Returns the current revision with metadata and backlinks.",
|
||||
Params: []Param{
|
||||
{Name: "slug", Type: "string", Required: true, Description: "Article slug to retrieve"},
|
||||
{Name: "include_history", Type: "boolean", Description: "Include revision history (default false)"},
|
||||
},
|
||||
Returns: "Article with body, metadata, outgoing links, backlinks, and optional revision history",
|
||||
Examples: []Example{{
|
||||
Description: "Read an article",
|
||||
Code: `call("get_article", {"slug": "mcp-security-landscape"})`,
|
||||
}},
|
||||
},
|
||||
{
|
||||
Name: "update_article",
|
||||
Category: "wiki",
|
||||
Description: "Update a wiki article's body and/or title. Creates a new revision (previous content preserved in history). Re-extracts [[backlinks]] from the new body.",
|
||||
Params: []Param{
|
||||
{Name: "slug", Type: "string", Required: true, Description: "Article slug to update"},
|
||||
{Name: "body", Type: "string", Required: true, Description: "New markdown body"},
|
||||
{Name: "title", Type: "string", Description: "New title (optional, keeps current if omitted)"},
|
||||
},
|
||||
Returns: "Updated article with new revision number",
|
||||
Examples: []Example{{
|
||||
Description: "Add a section to an existing article",
|
||||
Code: `call("update_article", {"slug": "mcp-security-landscape", "body": "# MCP Security\n\n## New Findings\n\n..."})`,
|
||||
}},
|
||||
},
|
||||
{
|
||||
Name: "list_articles",
|
||||
Category: "wiki",
|
||||
Description: "List or search wiki articles. Without a query, returns all articles sorted by last updated. With a query, searches titles and bodies using full-text search.",
|
||||
Params: []Param{
|
||||
{Name: "query", Type: "string", Description: "Search query (optional). Searches article titles and bodies."},
|
||||
{Name: "limit", Type: "number", Description: "Max results (default 50, max 200)"},
|
||||
},
|
||||
Returns: "Array of article summaries with slug, title, revision, updated_at, word_count",
|
||||
Examples: []Example{{
|
||||
Description: "Search for security-related articles",
|
||||
Code: `call("list_articles", {"query": "security vulnerability", "limit": 10})`,
|
||||
}},
|
||||
},
|
||||
{
|
||||
Name: "get_backlinks",
|
||||
Category: "wiki",
|
||||
Description: "Get all articles that link to a given article via [[slug]] references. Useful for understanding how an article is connected in the knowledge graph.",
|
||||
Params: []Param{
|
||||
{Name: "slug", Type: "string", Required: true, Description: "Article slug to find backlinks for"},
|
||||
},
|
||||
Returns: "Array of article summaries that contain [[slug]] links to this article",
|
||||
Examples: []Example{{
|
||||
Description: "Find articles linking to mcp-security",
|
||||
Code: `call("get_backlinks", {"slug": "mcp-security-landscape"})`,
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegistryHas30Actions(t *testing.T) {
|
||||
func TestRegistryHas35Actions(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
got := len(r.List())
|
||||
if got != 30 {
|
||||
t.Errorf("expected 30 actions, got %d", got)
|
||||
if got != 35 {
|
||||
t.Errorf("expected 35 actions, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ func TestRegistryCategories(t *testing.T) {
|
||||
{"reactions", 4},
|
||||
{"threads", 1},
|
||||
{"trust", 1},
|
||||
{"data", 1},
|
||||
{"wiki", 5},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -60,6 +62,8 @@ func TestRegistryGetByName(t *testing.T) {
|
||||
"get_trust",
|
||||
// data
|
||||
"query",
|
||||
// wiki
|
||||
"create_article", "get_article", "update_article", "list_articles", "get_backlinks",
|
||||
}
|
||||
|
||||
for _, name := range allNames {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
"github.com/synapbus/synapbus/internal/trust"
|
||||
"github.com/synapbus/synapbus/internal/webhooks"
|
||||
"github.com/synapbus/synapbus/internal/wiki"
|
||||
)
|
||||
|
||||
// RouterConfig holds optional services for the API router.
|
||||
@@ -40,6 +41,7 @@ type RouterConfig struct {
|
||||
TrustService *trust.Service
|
||||
ReactorStore *reactor.Store
|
||||
ReactorEngine *reactor.Reactor
|
||||
WikiService *wiki.Service
|
||||
SSEHub *SSEHub
|
||||
Broadcaster *SSEBroadcaster
|
||||
SessionMiddleware func(http.Handler) http.Handler
|
||||
@@ -265,6 +267,18 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
|
||||
})
|
||||
}
|
||||
|
||||
// Wiki
|
||||
if cfg.WikiService != nil {
|
||||
wikiHandler := NewWikiHandler(cfg.WikiService)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authMiddleware)
|
||||
r.Get("/api/wiki/articles", wikiHandler.ListArticles)
|
||||
r.Get("/api/wiki/articles/{slug}", wikiHandler.GetArticle)
|
||||
r.Get("/api/wiki/articles/{slug}/history", wikiHandler.GetHistory)
|
||||
r.Get("/api/wiki/map", wikiHandler.GetMap)
|
||||
})
|
||||
}
|
||||
|
||||
// Onboarding (CLAUDE.md generator, MCP config, archetypes, skills)
|
||||
if cfg.AgentService != nil {
|
||||
onboardingHandler := NewOnboardingHandler(cfg.AgentService, cfg.ChannelService, cfg.BaseURL)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/wiki"
|
||||
)
|
||||
|
||||
// WikiHandler handles REST API requests for wiki articles.
|
||||
type WikiHandler struct {
|
||||
wikiService *wiki.Service
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewWikiHandler creates a new wiki handler.
|
||||
func NewWikiHandler(svc *wiki.Service) *WikiHandler {
|
||||
return &WikiHandler{
|
||||
wikiService: svc,
|
||||
logger: slog.Default().With("component", "api.wiki"),
|
||||
}
|
||||
}
|
||||
|
||||
// ListArticles handles GET /api/wiki/articles?q=...&limit=50
|
||||
func (h *WikiHandler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
limit := 50
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if v, err := strconv.Atoi(l); err == nil && v > 0 {
|
||||
limit = v
|
||||
}
|
||||
}
|
||||
|
||||
articles, err := h.wikiService.ListArticles(r.Context(), query, limit)
|
||||
if err != nil {
|
||||
h.logger.Error("list articles failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("internal_error", "Failed to list articles"))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"articles": articles,
|
||||
"count": len(articles),
|
||||
})
|
||||
}
|
||||
|
||||
// GetArticle handles GET /api/wiki/articles/{slug}
|
||||
func (h *WikiHandler) GetArticle(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
if slug == "" {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "Slug is required"))
|
||||
return
|
||||
}
|
||||
|
||||
article, err := h.wikiService.GetArticle(r.Context(), slug)
|
||||
if err != nil {
|
||||
h.logger.Error("get article failed", "slug", slug, "error", err)
|
||||
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Article not found"))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, article)
|
||||
}
|
||||
|
||||
// GetHistory handles GET /api/wiki/articles/{slug}/history
|
||||
func (h *WikiHandler) GetHistory(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
if slug == "" {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "Slug is required"))
|
||||
return
|
||||
}
|
||||
|
||||
revisions, err := h.wikiService.GetRevisions(r.Context(), slug)
|
||||
if err != nil {
|
||||
h.logger.Error("get history failed", "slug", slug, "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("internal_error", "Failed to get history"))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"slug": slug,
|
||||
"revisions": revisions,
|
||||
"count": len(revisions),
|
||||
})
|
||||
}
|
||||
|
||||
// GetMap handles GET /api/wiki/map
|
||||
func (h *WikiHandler) GetMap(w http.ResponseWriter, r *http.Request) {
|
||||
moc, err := h.wikiService.GetMapOfContent(r.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("get map failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("internal_error", "Failed to get map"))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, moc)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/synapbus/synapbus/internal/reactions"
|
||||
"github.com/synapbus/synapbus/internal/search"
|
||||
"github.com/synapbus/synapbus/internal/trust"
|
||||
"github.com/synapbus/synapbus/internal/wiki"
|
||||
)
|
||||
|
||||
// ServiceBridge implements jsruntime.ToolCaller, mapping action names to
|
||||
@@ -32,6 +33,7 @@ type ServiceBridge struct {
|
||||
searchService *search.Service
|
||||
reactionService *reactions.Service
|
||||
trustService *trust.Service
|
||||
wikiService *wiki.Service
|
||||
queryExecutor *agentquery.Executor
|
||||
agentName string
|
||||
}
|
||||
@@ -46,6 +48,7 @@ func NewServiceBridge(
|
||||
searchService *search.Service,
|
||||
reactionService *reactions.Service,
|
||||
trustService *trust.Service,
|
||||
wikiService *wiki.Service,
|
||||
agentName string,
|
||||
) *ServiceBridge {
|
||||
return &ServiceBridge{
|
||||
@@ -57,6 +60,7 @@ func NewServiceBridge(
|
||||
searchService: searchService,
|
||||
reactionService: reactionService,
|
||||
trustService: trustService,
|
||||
wikiService: wikiService,
|
||||
agentName: agentName,
|
||||
}
|
||||
}
|
||||
@@ -136,6 +140,18 @@ func (b *ServiceBridge) Call(ctx context.Context, actionName string, args map[st
|
||||
case "query":
|
||||
return b.callQuery(ctx, args)
|
||||
|
||||
// --- Wiki ---
|
||||
case "create_article":
|
||||
return b.callCreateArticle(ctx, args)
|
||||
case "get_article":
|
||||
return b.callGetArticle(ctx, args)
|
||||
case "update_article":
|
||||
return b.callUpdateArticle(ctx, args)
|
||||
case "list_articles":
|
||||
return b.callListArticles(ctx, args)
|
||||
case "get_backlinks":
|
||||
return b.callGetBacklinks(ctx, args)
|
||||
|
||||
// --- DM send (also accessible via bridge for execute tool) ---
|
||||
case "send_message":
|
||||
return b.callSendMessage(ctx, args)
|
||||
@@ -1245,6 +1261,155 @@ func (b *ServiceBridge) callQuery(ctx context.Context, args map[string]any) (any
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- Wiki implementations ---
|
||||
|
||||
func (b *ServiceBridge) callCreateArticle(ctx context.Context, args map[string]any) (any, error) {
|
||||
if b.wikiService == nil {
|
||||
return nil, fmt.Errorf("wiki not available")
|
||||
}
|
||||
|
||||
slug := getString(args, "slug", "")
|
||||
if slug == "" {
|
||||
return nil, fmt.Errorf("'slug' parameter is required")
|
||||
}
|
||||
title := getString(args, "title", "")
|
||||
if title == "" {
|
||||
return nil, fmt.Errorf("'title' parameter is required")
|
||||
}
|
||||
body := getString(args, "body", "")
|
||||
if body == "" {
|
||||
return nil, fmt.Errorf("'body' parameter is required")
|
||||
}
|
||||
|
||||
article, err := b.wikiService.CreateArticle(ctx, slug, title, body, b.agentName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"id": article.ID,
|
||||
"slug": article.Slug,
|
||||
"title": article.Title,
|
||||
"revision": article.Revision,
|
||||
"word_count": article.WordCount,
|
||||
"created_at": article.CreatedAt,
|
||||
"outgoing_links": article.OutgoingLinks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *ServiceBridge) callGetArticle(ctx context.Context, args map[string]any) (any, error) {
|
||||
if b.wikiService == nil {
|
||||
return nil, fmt.Errorf("wiki not available")
|
||||
}
|
||||
|
||||
slug := getString(args, "slug", "")
|
||||
if slug == "" {
|
||||
return nil, fmt.Errorf("'slug' parameter is required")
|
||||
}
|
||||
|
||||
article, err := b.wikiService.GetArticle(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"id": article.ID,
|
||||
"slug": article.Slug,
|
||||
"title": article.Title,
|
||||
"body": article.Body,
|
||||
"created_by": article.CreatedBy,
|
||||
"updated_by": article.UpdatedBy,
|
||||
"revision": article.Revision,
|
||||
"word_count": article.WordCount,
|
||||
"created_at": article.CreatedAt,
|
||||
"updated_at": article.UpdatedAt,
|
||||
"outgoing_links": article.OutgoingLinks,
|
||||
"backlinks": article.Backlinks,
|
||||
}
|
||||
|
||||
if getBool(args, "include_history", false) {
|
||||
revisions, err := b.wikiService.GetRevisions(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result["revisions"] = revisions
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (b *ServiceBridge) callUpdateArticle(ctx context.Context, args map[string]any) (any, error) {
|
||||
if b.wikiService == nil {
|
||||
return nil, fmt.Errorf("wiki not available")
|
||||
}
|
||||
|
||||
slug := getString(args, "slug", "")
|
||||
if slug == "" {
|
||||
return nil, fmt.Errorf("'slug' parameter is required")
|
||||
}
|
||||
body := getString(args, "body", "")
|
||||
if body == "" {
|
||||
return nil, fmt.Errorf("'body' parameter is required")
|
||||
}
|
||||
title := getString(args, "title", "")
|
||||
|
||||
article, err := b.wikiService.UpdateArticle(ctx, slug, title, body, b.agentName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"id": article.ID,
|
||||
"slug": article.Slug,
|
||||
"title": article.Title,
|
||||
"revision": article.Revision,
|
||||
"word_count": article.WordCount,
|
||||
"updated_at": article.UpdatedAt,
|
||||
"outgoing_links": article.OutgoingLinks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *ServiceBridge) callListArticles(ctx context.Context, args map[string]any) (any, error) {
|
||||
if b.wikiService == nil {
|
||||
return nil, fmt.Errorf("wiki not available")
|
||||
}
|
||||
|
||||
query := getString(args, "query", "")
|
||||
limit := getInt(args, "limit", 50)
|
||||
|
||||
articles, err := b.wikiService.ListArticles(ctx, query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"articles": articles,
|
||||
"count": len(articles),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *ServiceBridge) callGetBacklinks(ctx context.Context, args map[string]any) (any, error) {
|
||||
if b.wikiService == nil {
|
||||
return nil, fmt.Errorf("wiki not available")
|
||||
}
|
||||
|
||||
slug := getString(args, "slug", "")
|
||||
if slug == "" {
|
||||
return nil, fmt.Errorf("'slug' parameter is required")
|
||||
}
|
||||
|
||||
backlinks, err := b.wikiService.GetBacklinks(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"slug": slug,
|
||||
"backlinks": backlinks,
|
||||
"count": len(backlinks),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// resolveChannelID resolves a channel ID from either channel_id or channel_name in args.
|
||||
|
||||
@@ -47,6 +47,7 @@ func newTestBridge(t *testing.T) (*ServiceBridge, *messaging.MessagingService, *
|
||||
nil, // searchService
|
||||
nil, // reactionService
|
||||
nil, // trustService
|
||||
nil, // wikiService
|
||||
"agent-a",
|
||||
)
|
||||
return bridge, msgService, agentService, channelService
|
||||
@@ -189,7 +190,7 @@ func TestBridge_JoinChannel(t *testing.T) {
|
||||
bridge.agentService,
|
||||
bridge.channelService,
|
||||
bridge.swarmService,
|
||||
nil, nil, nil, nil,
|
||||
nil, nil, nil, nil, nil,
|
||||
"agent-b",
|
||||
)
|
||||
|
||||
@@ -331,6 +332,7 @@ func newTestBridgeWithReactions(t *testing.T) (*ServiceBridge, *channels.Service
|
||||
nil, // searchService
|
||||
reactionService,
|
||||
nil, // trustService
|
||||
nil, // wikiService
|
||||
"agent-a",
|
||||
)
|
||||
return bridge, channelService
|
||||
|
||||
@@ -52,6 +52,7 @@ func newTestHybridWithChannels(t *testing.T) (*HybridToolRegistrar, *channels.Se
|
||||
nil, // searchService
|
||||
nil, // reactionService
|
||||
nil, // trustService
|
||||
nil, // wikiService
|
||||
jsPool,
|
||||
actionRegistry,
|
||||
actionIndex,
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/synapbus/synapbus/internal/search"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
"github.com/synapbus/synapbus/internal/trust"
|
||||
"github.com/synapbus/synapbus/internal/wiki"
|
||||
)
|
||||
|
||||
// MCPServer wraps the mcp-go server with SynapBus services.
|
||||
@@ -36,7 +37,7 @@ type MCPServer struct {
|
||||
console *console.Printer
|
||||
}
|
||||
|
||||
// NewMCPServer creates and configures a new MCP server with 4 hybrid tools registered.
|
||||
// NewMCPServer creates and configures a new MCP server with 5 hybrid tools registered.
|
||||
func NewMCPServer(
|
||||
msgService *messaging.MessagingService,
|
||||
agentService *agents.AgentService,
|
||||
@@ -46,6 +47,7 @@ func NewMCPServer(
|
||||
searchService *search.Service,
|
||||
reactionService *reactions.Service,
|
||||
trustService *trust.Service,
|
||||
wikiService *wiki.Service,
|
||||
consolePrinter *console.Printer,
|
||||
jsPool *jsruntime.Pool,
|
||||
actionRegistry *actions.Registry,
|
||||
@@ -161,6 +163,7 @@ func NewMCPServer(
|
||||
searchService,
|
||||
reactionService,
|
||||
trustService,
|
||||
wikiService,
|
||||
jsPool,
|
||||
actionRegistry,
|
||||
actionIndex,
|
||||
|
||||
@@ -38,7 +38,7 @@ func newTestMCPServer(t *testing.T, con *console.Printer) (*MCPServer, *messagin
|
||||
actionRegistry := actions.NewRegistry()
|
||||
actionIndex := actions.NewIndex(actionRegistry.List())
|
||||
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, con, jsPool, actionRegistry, actionIndex, db)
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil, con, jsPool, actionRegistry, actionIndex, db)
|
||||
return srv, msgService, agentService
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ func TestMCPToolCall_WithValidAPIKey(t *testing.T) {
|
||||
actionIndex := actions.NewIndex(actionRegistry.List())
|
||||
|
||||
// Create MCP server
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db)
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db)
|
||||
|
||||
// Mount with auth middleware, just like main.go does
|
||||
mux := http.NewServeMux()
|
||||
@@ -188,7 +188,7 @@ func TestMCPToolCall_InvalidAPIKeyReturns401(t *testing.T) {
|
||||
actionRegistry := actions.NewRegistry()
|
||||
actionIndex := actions.NewIndex(actionRegistry.List())
|
||||
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db)
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
handler := agents.OptionalAuthMiddlewareWithAPIKeys(agentService, apiKeyService)(srv.Handler())
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/synapbus/synapbus/internal/reactions"
|
||||
"github.com/synapbus/synapbus/internal/search"
|
||||
"github.com/synapbus/synapbus/internal/trust"
|
||||
"github.com/synapbus/synapbus/internal/wiki"
|
||||
)
|
||||
|
||||
// HybridToolRegistrar registers the 4 hybrid MCP tools.
|
||||
@@ -34,6 +35,7 @@ type HybridToolRegistrar struct {
|
||||
searchService *search.Service
|
||||
reactionService *reactions.Service
|
||||
trustService *trust.Service
|
||||
wikiService *wiki.Service
|
||||
jsPool *jsruntime.Pool
|
||||
actionRegistry *actions.Registry
|
||||
actionIndex *actions.Index
|
||||
@@ -57,6 +59,7 @@ func NewHybridToolRegistrar(
|
||||
searchService *search.Service,
|
||||
reactionService *reactions.Service,
|
||||
trustService *trust.Service,
|
||||
wikiService *wiki.Service,
|
||||
jsPool *jsruntime.Pool,
|
||||
actionRegistry *actions.Registry,
|
||||
actionIndex *actions.Index,
|
||||
@@ -71,6 +74,7 @@ func NewHybridToolRegistrar(
|
||||
searchService: searchService,
|
||||
reactionService: reactionService,
|
||||
trustService: trustService,
|
||||
wikiService: wikiService,
|
||||
jsPool: jsPool,
|
||||
actionRegistry: actionRegistry,
|
||||
actionIndex: actionIndex,
|
||||
@@ -500,6 +504,7 @@ func (h *HybridToolRegistrar) handleExecute(ctx context.Context, req mcplib.Call
|
||||
h.searchService,
|
||||
h.reactionService,
|
||||
h.trustService,
|
||||
h.wikiService,
|
||||
agentName,
|
||||
)
|
||||
if h.queryExecutor != nil {
|
||||
|
||||
@@ -70,6 +70,7 @@ func newTestHybridRegistrar(t *testing.T) (*HybridToolRegistrar, *messaging.Mess
|
||||
nil, // searchService
|
||||
nil, // reactionService
|
||||
nil, // trustService
|
||||
nil, // wikiService
|
||||
jsPool,
|
||||
actionRegistry,
|
||||
actionIndex,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
-- 017: Wiki articles, revisions, link graph, and FTS
|
||||
|
||||
-- Wiki articles
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE CHECK(length(slug) >= 2 AND length(slug) <= 100),
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL,
|
||||
updated_by TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_articles_slug ON articles(slug);
|
||||
CREATE INDEX idx_articles_updated_at ON articles(updated_at);
|
||||
|
||||
-- Revision history (full body per revision)
|
||||
CREATE TABLE IF NOT EXISTS article_revisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||
revision INTEGER NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
changed_by TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_article_revisions_article ON article_revisions(article_id, revision);
|
||||
|
||||
-- Link graph (rebuilt on every article create/update)
|
||||
CREATE TABLE IF NOT EXISTS article_links (
|
||||
from_slug TEXT NOT NULL,
|
||||
to_slug TEXT NOT NULL,
|
||||
display_text TEXT,
|
||||
PRIMARY KEY (from_slug, to_slug)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_article_links_to ON article_links(to_slug);
|
||||
|
||||
-- FTS5 for article search
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
|
||||
title,
|
||||
body,
|
||||
content='articles',
|
||||
content_rowid='id'
|
||||
);
|
||||
|
||||
-- FTS sync triggers
|
||||
CREATE TRIGGER articles_fts_ai AFTER INSERT ON articles BEGIN
|
||||
INSERT INTO articles_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
|
||||
END;
|
||||
CREATE TRIGGER articles_fts_ad AFTER DELETE ON articles BEGIN
|
||||
INSERT INTO articles_fts(articles_fts, rowid, title, body) VALUES('delete', old.id, old.title, old.body);
|
||||
END;
|
||||
CREATE TRIGGER articles_fts_au AFTER UPDATE ON articles BEGIN
|
||||
INSERT INTO articles_fts(articles_fts, rowid, title, body) VALUES('delete', old.id, old.title, old.body);
|
||||
INSERT INTO articles_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
|
||||
END;
|
||||
|
||||
INSERT INTO schema_migrations (version) VALUES (17);
|
||||
@@ -0,0 +1,63 @@
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// Service provides business logic for wiki articles.
|
||||
type Service struct {
|
||||
store *Store
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewService creates a new wiki service.
|
||||
func NewService(db *sql.DB) *Service {
|
||||
return &Service{
|
||||
store: NewStore(db),
|
||||
logger: slog.Default().With("component", "wiki"),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateArticle creates a new wiki article.
|
||||
func (s *Service) CreateArticle(ctx context.Context, slug, title, body, author string) (*Article, error) {
|
||||
s.logger.Info("creating article", "slug", slug, "author", author)
|
||||
return s.store.CreateArticle(ctx, slug, title, body, author)
|
||||
}
|
||||
|
||||
// GetArticle retrieves an article by slug.
|
||||
func (s *Service) GetArticle(ctx context.Context, slug string) (*Article, error) {
|
||||
return s.store.GetArticle(ctx, slug)
|
||||
}
|
||||
|
||||
// UpdateArticle updates an article's body and/or title.
|
||||
func (s *Service) UpdateArticle(ctx context.Context, slug, title, body, author string) (*Article, error) {
|
||||
s.logger.Info("updating article", "slug", slug, "author", author)
|
||||
return s.store.UpdateArticle(ctx, slug, title, body, author)
|
||||
}
|
||||
|
||||
// ListArticles lists or searches wiki articles.
|
||||
func (s *Service) ListArticles(ctx context.Context, query string, limit int) ([]ArticleSummary, error) {
|
||||
return s.store.ListArticles(ctx, query, limit)
|
||||
}
|
||||
|
||||
// GetRevisions returns the revision history for an article.
|
||||
func (s *Service) GetRevisions(ctx context.Context, slug string) ([]ArticleRevision, error) {
|
||||
return s.store.GetRevisions(ctx, slug)
|
||||
}
|
||||
|
||||
// GetBacklinks returns articles linking to the given slug.
|
||||
func (s *Service) GetBacklinks(ctx context.Context, slug string) ([]ArticleSummary, error) {
|
||||
return s.store.GetBacklinks(ctx, slug)
|
||||
}
|
||||
|
||||
// GetOutgoingLinks returns links from the given article.
|
||||
func (s *Service) GetOutgoingLinks(ctx context.Context, slug string) ([]ArticleLink, error) {
|
||||
return s.store.GetOutgoingLinks(ctx, slug)
|
||||
}
|
||||
|
||||
// GetMapOfContent returns the wiki knowledge graph overview.
|
||||
func (s *Service) GetMapOfContent(ctx context.Context) (*MapOfContent, error) {
|
||||
return s.store.GetMapOfContent(ctx)
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// slugRe validates article slugs: lowercase letters, digits, hyphens, 2-100 chars.
|
||||
var slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`)
|
||||
|
||||
// wikiLinkRe matches [[slug]] or [[slug|Display Text]] in article bodies.
|
||||
var wikiLinkRe = regexp.MustCompile(`\[\[([a-z0-9][a-z0-9-]*[a-z0-9])(?:\|([^\]]+))?\]\]`)
|
||||
|
||||
// ValidateSlug checks that a slug meets the wiki naming rules.
|
||||
func ValidateSlug(slug string) error {
|
||||
if len(slug) < 2 || len(slug) > 100 {
|
||||
return fmt.Errorf("slug must be 2-100 characters")
|
||||
}
|
||||
if !slugRe.MatchString(slug) {
|
||||
return fmt.Errorf("slug must be lowercase letters, numbers, and hyphens (e.g. 'mcp-security')")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtractLinks parses [[slug]] and [[slug|display]] references from markdown body.
|
||||
func ExtractLinks(body string) []ArticleLink {
|
||||
matches := wikiLinkRe.FindAllStringSubmatch(body, -1)
|
||||
seen := make(map[string]bool)
|
||||
var links []ArticleLink
|
||||
for _, m := range matches {
|
||||
slug := m[1]
|
||||
if seen[slug] {
|
||||
continue
|
||||
}
|
||||
seen[slug] = true
|
||||
link := ArticleLink{ToSlug: slug}
|
||||
if len(m) > 2 && m[2] != "" {
|
||||
link.DisplayText = m[2]
|
||||
}
|
||||
links = append(links, link)
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
// WordCount returns the number of whitespace-delimited words in s.
|
||||
func WordCount(s string) int {
|
||||
return len(strings.Fields(s))
|
||||
}
|
||||
|
||||
// Store provides SQLite-backed CRUD operations for wiki articles.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore creates a new wiki store.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// CreateArticle inserts a new article, its first revision, and link graph entries.
|
||||
func (s *Store) CreateArticle(ctx context.Context, slug, title, body, author string) (*Article, error) {
|
||||
if err := ValidateSlug(slug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
result, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO articles (slug, title, body, created_by, updated_by, revision, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
slug, title, body, author, author,
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return nil, fmt.Errorf("article with slug %q already exists", slug)
|
||||
}
|
||||
return nil, fmt.Errorf("insert article: %w", err)
|
||||
}
|
||||
|
||||
articleID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get article id: %w", err)
|
||||
}
|
||||
|
||||
// Insert first revision
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`INSERT INTO article_revisions (article_id, revision, body, changed_by, created_at)
|
||||
VALUES (?, 1, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
articleID, body, author,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert revision: %w", err)
|
||||
}
|
||||
|
||||
// Extract and store links
|
||||
if err := s.updateLinks(ctx, tx, slug, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
|
||||
return s.GetArticle(ctx, slug)
|
||||
}
|
||||
|
||||
// GetArticle retrieves the current version of an article by slug.
|
||||
func (s *Store) GetArticle(ctx context.Context, slug string) (*Article, error) {
|
||||
var a Article
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, slug, title, body, created_by, updated_by, revision, created_at, updated_at
|
||||
FROM articles WHERE slug = ?`, slug,
|
||||
).Scan(&a.ID, &a.Slug, &a.Title, &a.Body, &a.CreatedBy, &a.UpdatedBy, &a.Revision, &a.CreatedAt, &a.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("article %q not found", slug)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get article: %w", err)
|
||||
}
|
||||
|
||||
a.WordCount = WordCount(a.Body)
|
||||
|
||||
// Get outgoing links
|
||||
outgoing, err := s.GetOutgoingLinks(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.OutgoingLinks = outgoing
|
||||
|
||||
// Get backlinks
|
||||
backlinks, err := s.GetBacklinks(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.Backlinks = backlinks
|
||||
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// UpdateArticle updates an article's body and/or title, creating a new revision.
|
||||
func (s *Store) UpdateArticle(ctx context.Context, slug, title, body, author string) (*Article, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Get current article
|
||||
var articleID int64
|
||||
var currentRevision int
|
||||
var currentTitle string
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`SELECT id, revision, title FROM articles WHERE slug = ?`, slug,
|
||||
).Scan(&articleID, ¤tRevision, ¤tTitle)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("article %q not found", slug)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get article for update: %w", err)
|
||||
}
|
||||
|
||||
newRevision := currentRevision + 1
|
||||
if title == "" {
|
||||
title = currentTitle
|
||||
}
|
||||
|
||||
// Update article
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`UPDATE articles SET title = ?, body = ?, updated_by = ?, revision = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE slug = ?`,
|
||||
title, body, author, newRevision, slug,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update article: %w", err)
|
||||
}
|
||||
|
||||
// Insert new revision
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`INSERT INTO article_revisions (article_id, revision, body, changed_by, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
articleID, newRevision, body, author,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert revision: %w", err)
|
||||
}
|
||||
|
||||
// Re-extract and store links
|
||||
if err := s.updateLinks(ctx, tx, slug, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
|
||||
return s.GetArticle(ctx, slug)
|
||||
}
|
||||
|
||||
// ListArticles returns article summaries, optionally filtered by FTS query.
|
||||
func (s *Store) ListArticles(ctx context.Context, query string, limit int) ([]ArticleSummary, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
if query != "" {
|
||||
sanitized := sanitizeFTS5Query(query)
|
||||
rows, err = s.db.QueryContext(ctx,
|
||||
`SELECT a.slug, a.title, a.updated_at, a.revision, a.body
|
||||
FROM articles a
|
||||
JOIN articles_fts ON articles_fts.rowid = a.id
|
||||
WHERE articles_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?`, sanitized, limit,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.QueryContext(ctx,
|
||||
`SELECT slug, title, updated_at, revision, body
|
||||
FROM articles
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?`, limit,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list articles: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var articles []ArticleSummary
|
||||
for rows.Next() {
|
||||
var as ArticleSummary
|
||||
var body string
|
||||
if err := rows.Scan(&as.Slug, &as.Title, &as.UpdatedAt, &as.Revision, &body); err != nil {
|
||||
return nil, fmt.Errorf("scan article: %w", err)
|
||||
}
|
||||
as.WordCount = WordCount(body)
|
||||
articles = append(articles, as)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate articles: %w", err)
|
||||
}
|
||||
|
||||
if articles == nil {
|
||||
articles = []ArticleSummary{}
|
||||
}
|
||||
return articles, nil
|
||||
}
|
||||
|
||||
// GetRevisions returns the revision history for an article.
|
||||
func (s *Store) GetRevisions(ctx context.Context, slug string) ([]ArticleRevision, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT r.id, r.revision, r.body, r.changed_by, r.created_at
|
||||
FROM article_revisions r
|
||||
JOIN articles a ON a.id = r.article_id
|
||||
WHERE a.slug = ?
|
||||
ORDER BY r.revision DESC`, slug,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get revisions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var revisions []ArticleRevision
|
||||
for rows.Next() {
|
||||
var rev ArticleRevision
|
||||
if err := rows.Scan(&rev.ID, &rev.Revision, &rev.Body, &rev.ChangedBy, &rev.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan revision: %w", err)
|
||||
}
|
||||
rev.WordCount = WordCount(rev.Body)
|
||||
revisions = append(revisions, rev)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate revisions: %w", err)
|
||||
}
|
||||
|
||||
if revisions == nil {
|
||||
revisions = []ArticleRevision{}
|
||||
}
|
||||
return revisions, nil
|
||||
}
|
||||
|
||||
// GetBacklinks returns articles that link TO the given slug.
|
||||
func (s *Store) GetBacklinks(ctx context.Context, slug string) ([]ArticleSummary, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT a.slug, a.title, a.updated_at, a.revision, a.body
|
||||
FROM articles a
|
||||
JOIN article_links l ON l.from_slug = a.slug
|
||||
WHERE l.to_slug = ?
|
||||
ORDER BY a.updated_at DESC`, slug,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get backlinks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var backlinks []ArticleSummary
|
||||
for rows.Next() {
|
||||
var as ArticleSummary
|
||||
var body string
|
||||
if err := rows.Scan(&as.Slug, &as.Title, &as.UpdatedAt, &as.Revision, &body); err != nil {
|
||||
return nil, fmt.Errorf("scan backlink: %w", err)
|
||||
}
|
||||
as.WordCount = WordCount(body)
|
||||
backlinks = append(backlinks, as)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate backlinks: %w", err)
|
||||
}
|
||||
|
||||
if backlinks == nil {
|
||||
backlinks = []ArticleSummary{}
|
||||
}
|
||||
return backlinks, nil
|
||||
}
|
||||
|
||||
// GetOutgoingLinks returns links FROM the given article.
|
||||
func (s *Store) GetOutgoingLinks(ctx context.Context, slug string) ([]ArticleLink, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT from_slug, to_slug, COALESCE(display_text, '') FROM article_links WHERE from_slug = ?`, slug,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get outgoing links: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var links []ArticleLink
|
||||
for rows.Next() {
|
||||
var l ArticleLink
|
||||
if err := rows.Scan(&l.FromSlug, &l.ToSlug, &l.DisplayText); err != nil {
|
||||
return nil, fmt.Errorf("scan link: %w", err)
|
||||
}
|
||||
links = append(links, l)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate links: %w", err)
|
||||
}
|
||||
|
||||
if links == nil {
|
||||
links = []ArticleLink{}
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
|
||||
// GetMapOfContent returns a bird's-eye view of the wiki knowledge graph.
|
||||
func (s *Store) GetMapOfContent(ctx context.Context) (*MapOfContent, error) {
|
||||
moc := &MapOfContent{}
|
||||
|
||||
// Get all articles
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT slug, title, updated_at, revision, body FROM articles ORDER BY updated_at DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get all articles: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type articleInfo struct {
|
||||
summary ArticleSummary
|
||||
}
|
||||
|
||||
var allArticles []articleInfo
|
||||
slugSet := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var as ArticleSummary
|
||||
var body string
|
||||
if err := rows.Scan(&as.Slug, &as.Title, &as.UpdatedAt, &as.Revision, &body); err != nil {
|
||||
return nil, fmt.Errorf("scan article: %w", err)
|
||||
}
|
||||
as.WordCount = WordCount(body)
|
||||
allArticles = append(allArticles, articleInfo{summary: as})
|
||||
slugSet[as.Slug] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate articles: %w", err)
|
||||
}
|
||||
|
||||
moc.Total = len(allArticles)
|
||||
|
||||
// Get backlink counts per article
|
||||
backlinkCounts := make(map[string]int)
|
||||
blRows, err := s.db.QueryContext(ctx,
|
||||
`SELECT to_slug, COUNT(*) FROM article_links GROUP BY to_slug`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get backlink counts: %w", err)
|
||||
}
|
||||
defer blRows.Close()
|
||||
for blRows.Next() {
|
||||
var slug string
|
||||
var count int
|
||||
if err := blRows.Scan(&slug, &count); err != nil {
|
||||
return nil, fmt.Errorf("scan backlink count: %w", err)
|
||||
}
|
||||
backlinkCounts[slug] = count
|
||||
}
|
||||
if err := blRows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate backlink counts: %w", err)
|
||||
}
|
||||
|
||||
// Get outgoing link counts per article
|
||||
outgoingCounts := make(map[string]int)
|
||||
olRows, err := s.db.QueryContext(ctx,
|
||||
`SELECT from_slug, COUNT(*) FROM article_links GROUP BY from_slug`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get outgoing counts: %w", err)
|
||||
}
|
||||
defer olRows.Close()
|
||||
for olRows.Next() {
|
||||
var slug string
|
||||
var count int
|
||||
if err := olRows.Scan(&slug, &count); err != nil {
|
||||
return nil, fmt.Errorf("scan outgoing count: %w", err)
|
||||
}
|
||||
outgoingCounts[slug] = count
|
||||
}
|
||||
if err := olRows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate outgoing counts: %w", err)
|
||||
}
|
||||
|
||||
// Classify articles
|
||||
for _, ai := range allArticles {
|
||||
blCount := backlinkCounts[ai.summary.Slug]
|
||||
|
||||
// Hubs: articles with 2+ backlinks
|
||||
if blCount >= 2 {
|
||||
moc.Hubs = append(moc.Hubs, ArticleWithLinks{
|
||||
ArticleSummary: ai.summary,
|
||||
BacklinkCount: blCount,
|
||||
})
|
||||
}
|
||||
|
||||
// Orphans: articles with no backlinks AND no outgoing links
|
||||
if blCount == 0 && outgoingCounts[ai.summary.Slug] == 0 {
|
||||
moc.Orphans = append(moc.Orphans, ai.summary)
|
||||
}
|
||||
|
||||
moc.Articles = append(moc.Articles, ai.summary)
|
||||
}
|
||||
|
||||
// Wanted: slugs referenced in links but not existing as articles
|
||||
wantedRows, err := s.db.QueryContext(ctx,
|
||||
`SELECT to_slug, COUNT(*) as cnt
|
||||
FROM article_links
|
||||
WHERE to_slug NOT IN (SELECT slug FROM articles)
|
||||
GROUP BY to_slug
|
||||
ORDER BY cnt DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get wanted articles: %w", err)
|
||||
}
|
||||
defer wantedRows.Close()
|
||||
for wantedRows.Next() {
|
||||
var w WantedArticle
|
||||
if err := wantedRows.Scan(&w.Slug, &w.ReferencedCount); err != nil {
|
||||
return nil, fmt.Errorf("scan wanted: %w", err)
|
||||
}
|
||||
moc.Wanted = append(moc.Wanted, w)
|
||||
}
|
||||
if err := wantedRows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate wanted: %w", err)
|
||||
}
|
||||
|
||||
// Ensure non-nil slices for JSON
|
||||
if moc.Hubs == nil {
|
||||
moc.Hubs = []ArticleWithLinks{}
|
||||
}
|
||||
if moc.Articles == nil {
|
||||
moc.Articles = []ArticleSummary{}
|
||||
}
|
||||
if moc.Orphans == nil {
|
||||
moc.Orphans = []ArticleSummary{}
|
||||
}
|
||||
if moc.Wanted == nil {
|
||||
moc.Wanted = []WantedArticle{}
|
||||
}
|
||||
|
||||
return moc, nil
|
||||
}
|
||||
|
||||
// updateLinks deletes old links from the article and inserts new ones extracted from body.
|
||||
func (s *Store) updateLinks(ctx context.Context, tx *sql.Tx, fromSlug, body string) error {
|
||||
_, err := tx.ExecContext(ctx, `DELETE FROM article_links WHERE from_slug = ?`, fromSlug)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete old links: %w", err)
|
||||
}
|
||||
|
||||
links := ExtractLinks(body)
|
||||
for _, l := range links {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT OR REPLACE INTO article_links (from_slug, to_slug, display_text) VALUES (?, ?, ?)`,
|
||||
fromSlug, l.ToSlug, l.DisplayText,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert link %s -> %s: %w", fromSlug, l.ToSlug, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeFTS5Query escapes FTS5 reserved words and operators by wrapping each
|
||||
// token in double quotes, turning them into literal phrase tokens.
|
||||
func sanitizeFTS5Query(query string) string {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
tokens := strings.Fields(query)
|
||||
quoted := make([]string, 0, len(tokens))
|
||||
for _, tok := range tokens {
|
||||
// Already double-quoted — leave as-is
|
||||
if len(tok) >= 2 && tok[0] == '"' && tok[len(tok)-1] == '"' {
|
||||
quoted = append(quoted, tok)
|
||||
continue
|
||||
}
|
||||
// Wrap in double quotes to make it a literal FTS5 phrase.
|
||||
// Escape any embedded double quotes by doubling them.
|
||||
escaped := strings.ReplaceAll(tok, `"`, `""`)
|
||||
quoted = append(quoted, `"`+escaped+`"`)
|
||||
}
|
||||
return strings.Join(quoted, " ")
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/storage"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||
t.Fatalf("enable foreign keys: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if err := storage.RunMigrations(ctx, db); err != nil {
|
||||
t.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func TestCreateArticle(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("create success", func(t *testing.T) {
|
||||
a, err := store.CreateArticle(ctx, "mcp-security", "MCP Security", "# Security\n\nMCP security overview", "agent-a")
|
||||
if err != nil {
|
||||
t.Fatalf("create article: %v", err)
|
||||
}
|
||||
if a.Slug != "mcp-security" {
|
||||
t.Errorf("slug = %q, want %q", a.Slug, "mcp-security")
|
||||
}
|
||||
if a.Title != "MCP Security" {
|
||||
t.Errorf("title = %q, want %q", a.Title, "MCP Security")
|
||||
}
|
||||
if a.Revision != 1 {
|
||||
t.Errorf("revision = %d, want 1", a.Revision)
|
||||
}
|
||||
if a.CreatedBy != "agent-a" {
|
||||
t.Errorf("created_by = %q, want %q", a.CreatedBy, "agent-a")
|
||||
}
|
||||
if a.WordCount != 5 {
|
||||
t.Errorf("word_count = %d, want 5", a.WordCount)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate slug error", func(t *testing.T) {
|
||||
_, err := store.CreateArticle(ctx, "mcp-security", "Another", "body", "agent-b")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate slug")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid slug error", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
slug string
|
||||
}{
|
||||
{""},
|
||||
{"a"},
|
||||
{"A-B"},
|
||||
{"-abc"},
|
||||
{"abc-"},
|
||||
{"a b c"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
_, err := store.CreateArticle(ctx, tt.slug, "Title", "Body", "agent-a")
|
||||
if err == nil {
|
||||
t.Errorf("expected error for slug %q", tt.slug)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create with links", func(t *testing.T) {
|
||||
a, err := store.CreateArticle(ctx, "overview-page", "Overview", "See [[mcp-security]] and [[a2a-protocols|A2A]]", "agent-a")
|
||||
if err != nil {
|
||||
t.Fatalf("create article: %v", err)
|
||||
}
|
||||
if len(a.OutgoingLinks) != 2 {
|
||||
t.Fatalf("outgoing links = %d, want 2", len(a.OutgoingLinks))
|
||||
}
|
||||
linkMap := make(map[string]string)
|
||||
for _, l := range a.OutgoingLinks {
|
||||
linkMap[l.ToSlug] = l.DisplayText
|
||||
}
|
||||
if _, ok := linkMap["mcp-security"]; !ok {
|
||||
t.Error("expected link to mcp-security")
|
||||
}
|
||||
if dt, ok := linkMap["a2a-protocols"]; !ok {
|
||||
t.Error("expected link to a2a-protocols")
|
||||
} else if dt != "A2A" {
|
||||
t.Errorf("a2a-protocols display_text = %q, want %q", dt, "A2A")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetArticle(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("exists", func(t *testing.T) {
|
||||
store.CreateArticle(ctx, "test-get", "Test Get", "Body here", "agent-a")
|
||||
a, err := store.GetArticle(ctx, "test-get")
|
||||
if err != nil {
|
||||
t.Fatalf("get article: %v", err)
|
||||
}
|
||||
if a.Slug != "test-get" {
|
||||
t.Errorf("slug = %q, want %q", a.Slug, "test-get")
|
||||
}
|
||||
if a.Body != "Body here" {
|
||||
t.Errorf("body = %q, want %q", a.Body, "Body here")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
_, err := store.GetArticle(ctx, "nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent slug")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateArticle(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("update body and revision increments", func(t *testing.T) {
|
||||
store.CreateArticle(ctx, "update-me", "Update Me", "Original body", "agent-a")
|
||||
|
||||
a, err := store.UpdateArticle(ctx, "update-me", "", "New body with [[linked-article]]", "agent-b")
|
||||
if err != nil {
|
||||
t.Fatalf("update article: %v", err)
|
||||
}
|
||||
if a.Revision != 2 {
|
||||
t.Errorf("revision = %d, want 2", a.Revision)
|
||||
}
|
||||
if a.Body != "New body with [[linked-article]]" {
|
||||
t.Errorf("body = %q, want updated body", a.Body)
|
||||
}
|
||||
if a.UpdatedBy != "agent-b" {
|
||||
t.Errorf("updated_by = %q, want %q", a.UpdatedBy, "agent-b")
|
||||
}
|
||||
if a.Title != "Update Me" {
|
||||
t.Errorf("title = %q, want %q (should keep original)", a.Title, "Update Me")
|
||||
}
|
||||
if len(a.OutgoingLinks) != 1 {
|
||||
t.Errorf("outgoing links = %d, want 1", len(a.OutgoingLinks))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("update title", func(t *testing.T) {
|
||||
a, err := store.UpdateArticle(ctx, "update-me", "New Title", "New body v3", "agent-a")
|
||||
if err != nil {
|
||||
t.Fatalf("update article: %v", err)
|
||||
}
|
||||
if a.Title != "New Title" {
|
||||
t.Errorf("title = %q, want %q", a.Title, "New Title")
|
||||
}
|
||||
if a.Revision != 3 {
|
||||
t.Errorf("revision = %d, want 3", a.Revision)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("links re-extracted on update", func(t *testing.T) {
|
||||
store.CreateArticle(ctx, "link-test", "Link Test", "See [[update-me]]", "agent-a")
|
||||
|
||||
// Verify backlinks to update-me
|
||||
backlinks, err := store.GetBacklinks(ctx, "update-me")
|
||||
if err != nil {
|
||||
t.Fatalf("get backlinks: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, bl := range backlinks {
|
||||
if bl.Slug == "link-test" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected backlink from link-test to update-me")
|
||||
}
|
||||
|
||||
// Update link-test to remove the link
|
||||
store.UpdateArticle(ctx, "link-test", "", "No more links here", "agent-a")
|
||||
backlinks, err = store.GetBacklinks(ctx, "update-me")
|
||||
if err != nil {
|
||||
t.Fatalf("get backlinks after update: %v", err)
|
||||
}
|
||||
for _, bl := range backlinks {
|
||||
if bl.Slug == "link-test" {
|
||||
t.Error("backlink from link-test should have been removed")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("update nonexistent", func(t *testing.T) {
|
||||
_, err := store.UpdateArticle(ctx, "no-such-article", "", "body", "agent-a")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent slug")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListArticles(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
store.CreateArticle(ctx, "alpha-topic", "Alpha Topic", "This is about alpha testing", "agent-a")
|
||||
store.CreateArticle(ctx, "beta-topic", "Beta Topic", "This is about beta testing", "agent-a")
|
||||
store.CreateArticle(ctx, "gamma-topic", "Gamma Topic", "Gamma is totally different", "agent-a")
|
||||
|
||||
t.Run("list all", func(t *testing.T) {
|
||||
articles, err := store.ListArticles(ctx, "", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("list articles: %v", err)
|
||||
}
|
||||
if len(articles) != 3 {
|
||||
t.Errorf("count = %d, want 3", len(articles))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FTS search", func(t *testing.T) {
|
||||
articles, err := store.ListArticles(ctx, "alpha", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("search articles: %v", err)
|
||||
}
|
||||
if len(articles) != 1 {
|
||||
t.Errorf("count = %d, want 1", len(articles))
|
||||
}
|
||||
if len(articles) > 0 && articles[0].Slug != "alpha-topic" {
|
||||
t.Errorf("slug = %q, want %q", articles[0].Slug, "alpha-topic")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FTS search title", func(t *testing.T) {
|
||||
articles, err := store.ListArticles(ctx, "Gamma", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("search articles: %v", err)
|
||||
}
|
||||
if len(articles) != 1 {
|
||||
t.Errorf("count = %d, want 1", len(articles))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("limit", func(t *testing.T) {
|
||||
articles, err := store.ListArticles(ctx, "", 2)
|
||||
if err != nil {
|
||||
t.Fatalf("list articles: %v", err)
|
||||
}
|
||||
if len(articles) != 2 {
|
||||
t.Errorf("count = %d, want 2", len(articles))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no results", func(t *testing.T) {
|
||||
articles, err := store.ListArticles(ctx, "zzzznonexistent", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("search articles: %v", err)
|
||||
}
|
||||
if len(articles) != 0 {
|
||||
t.Errorf("count = %d, want 0", len(articles))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetRevisions(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
store.CreateArticle(ctx, "rev-test", "Rev Test", "Version 1 body", "agent-a")
|
||||
store.UpdateArticle(ctx, "rev-test", "", "Version 2 body", "agent-b")
|
||||
store.UpdateArticle(ctx, "rev-test", "", "Version 3 body", "agent-a")
|
||||
|
||||
revisions, err := store.GetRevisions(ctx, "rev-test")
|
||||
if err != nil {
|
||||
t.Fatalf("get revisions: %v", err)
|
||||
}
|
||||
if len(revisions) != 3 {
|
||||
t.Fatalf("revision count = %d, want 3", len(revisions))
|
||||
}
|
||||
// Revisions are ordered DESC
|
||||
if revisions[0].Revision != 3 {
|
||||
t.Errorf("latest revision = %d, want 3", revisions[0].Revision)
|
||||
}
|
||||
if revisions[0].ChangedBy != "agent-a" {
|
||||
t.Errorf("latest changed_by = %q, want %q", revisions[0].ChangedBy, "agent-a")
|
||||
}
|
||||
if revisions[2].Revision != 1 {
|
||||
t.Errorf("oldest revision = %d, want 1", revisions[2].Revision)
|
||||
}
|
||||
if revisions[2].Body != "Version 1 body" {
|
||||
t.Errorf("oldest body = %q, want %q", revisions[2].Body, "Version 1 body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBacklinks(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
store.CreateArticle(ctx, "target-article", "Target", "I am the target", "agent-a")
|
||||
store.CreateArticle(ctx, "linker-one", "Linker One", "See [[target-article]] for details", "agent-a")
|
||||
store.CreateArticle(ctx, "linker-two", "Linker Two", "Also references [[target-article]]", "agent-b")
|
||||
store.CreateArticle(ctx, "unrelated", "Unrelated", "No links here", "agent-a")
|
||||
|
||||
backlinks, err := store.GetBacklinks(ctx, "target-article")
|
||||
if err != nil {
|
||||
t.Fatalf("get backlinks: %v", err)
|
||||
}
|
||||
if len(backlinks) != 2 {
|
||||
t.Fatalf("backlink count = %d, want 2", len(backlinks))
|
||||
}
|
||||
|
||||
slugs := map[string]bool{}
|
||||
for _, bl := range backlinks {
|
||||
slugs[bl.Slug] = true
|
||||
}
|
||||
if !slugs["linker-one"] {
|
||||
t.Error("expected backlink from linker-one")
|
||||
}
|
||||
if !slugs["linker-two"] {
|
||||
t.Error("expected backlink from linker-two")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want int
|
||||
slugs []string
|
||||
}{
|
||||
{
|
||||
name: "simple link",
|
||||
body: "See [[mcp-security]] for details",
|
||||
want: 1,
|
||||
slugs: []string{"mcp-security"},
|
||||
},
|
||||
{
|
||||
name: "link with display text",
|
||||
body: "Check [[a2a-protocols|A2A Protocols]]",
|
||||
want: 1,
|
||||
slugs: []string{"a2a-protocols"},
|
||||
},
|
||||
{
|
||||
name: "multiple links",
|
||||
body: "See [[alpha-topic]] and [[beta-topic]] and [[gamma-topic]]",
|
||||
want: 3,
|
||||
slugs: []string{"alpha-topic", "beta-topic", "gamma-topic"},
|
||||
},
|
||||
{
|
||||
name: "duplicate links deduped",
|
||||
body: "[[mcp-security]] is great. Also see [[mcp-security]]",
|
||||
want: 1,
|
||||
slugs: []string{"mcp-security"},
|
||||
},
|
||||
{
|
||||
name: "no matches",
|
||||
body: "No wiki links here at all",
|
||||
want: 0,
|
||||
slugs: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid slug format not matched",
|
||||
body: "[[A]] and [[-abc]] and [[abc-]] are invalid",
|
||||
want: 0,
|
||||
slugs: nil,
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid",
|
||||
body: "Valid: [[good-slug]] Invalid: [[A]] [[ab]]",
|
||||
want: 2,
|
||||
slugs: []string{"good-slug", "ab"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
links := ExtractLinks(tt.body)
|
||||
if len(links) != tt.want {
|
||||
t.Errorf("link count = %d, want %d", len(links), tt.want)
|
||||
}
|
||||
for i, slug := range tt.slugs {
|
||||
if i < len(links) && links[i].ToSlug != slug {
|
||||
t.Errorf("link[%d].to_slug = %q, want %q", i, links[i].ToSlug, slug)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSlug(t *testing.T) {
|
||||
tests := []struct {
|
||||
slug string
|
||||
wantErr bool
|
||||
}{
|
||||
{"ab", false},
|
||||
{"mcp-security", false},
|
||||
{"a2a-protocols", false},
|
||||
{"topic123", false},
|
||||
{"a", true}, // too short
|
||||
{"", true}, // empty
|
||||
{"A-B", true}, // uppercase
|
||||
{"-abc", true}, // starts with hyphen
|
||||
{"abc-", true}, // ends with hyphen
|
||||
{"a b c", true}, // spaces
|
||||
{"a_b", true}, // underscores
|
||||
{"ab!c", true}, // special chars
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.slug, func(t *testing.T) {
|
||||
err := ValidateSlug(tt.slug)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateSlug(%q) error = %v, wantErr %v", tt.slug, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMapOfContent(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a hub (article with 2+ backlinks)
|
||||
store.CreateArticle(ctx, "hub-article", "Hub", "I am a hub", "agent-a")
|
||||
store.CreateArticle(ctx, "spoke-one", "Spoke 1", "Links to [[hub-article]]", "agent-a")
|
||||
store.CreateArticle(ctx, "spoke-two", "Spoke 2", "Also links to [[hub-article]]", "agent-b")
|
||||
|
||||
// Create an orphan (no links in or out)
|
||||
store.CreateArticle(ctx, "orphan-article", "Orphan", "I am alone", "agent-a")
|
||||
|
||||
// Create a wanted article reference (referenced but not created)
|
||||
store.CreateArticle(ctx, "has-wanted", "Has Wanted", "See [[nonexistent-topic]]", "agent-a")
|
||||
|
||||
moc, err := store.GetMapOfContent(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("get map: %v", err)
|
||||
}
|
||||
|
||||
if moc.Total != 5 {
|
||||
t.Errorf("total = %d, want 5", moc.Total)
|
||||
}
|
||||
|
||||
// Check hubs
|
||||
if len(moc.Hubs) != 1 {
|
||||
t.Errorf("hubs count = %d, want 1", len(moc.Hubs))
|
||||
} else if moc.Hubs[0].Slug != "hub-article" {
|
||||
t.Errorf("hub slug = %q, want %q", moc.Hubs[0].Slug, "hub-article")
|
||||
} else if moc.Hubs[0].BacklinkCount != 2 {
|
||||
t.Errorf("hub backlink count = %d, want 2", moc.Hubs[0].BacklinkCount)
|
||||
}
|
||||
|
||||
// Check orphans
|
||||
if len(moc.Orphans) != 1 {
|
||||
t.Errorf("orphans count = %d, want 1", len(moc.Orphans))
|
||||
} else if moc.Orphans[0].Slug != "orphan-article" {
|
||||
t.Errorf("orphan slug = %q, want %q", moc.Orphans[0].Slug, "orphan-article")
|
||||
}
|
||||
|
||||
// Check wanted
|
||||
if len(moc.Wanted) != 1 {
|
||||
t.Errorf("wanted count = %d, want 1", len(moc.Wanted))
|
||||
} else if moc.Wanted[0].Slug != "nonexistent-topic" {
|
||||
t.Errorf("wanted slug = %q, want %q", moc.Wanted[0].Slug, "nonexistent-topic")
|
||||
} else if moc.Wanted[0].ReferencedCount != 1 {
|
||||
t.Errorf("wanted ref count = %d, want 1", moc.Wanted[0].ReferencedCount)
|
||||
}
|
||||
|
||||
// All articles listed
|
||||
if len(moc.Articles) != 5 {
|
||||
t.Errorf("articles count = %d, want 5", len(moc.Articles))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordCount(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int
|
||||
}{
|
||||
{"", 0},
|
||||
{"hello", 1},
|
||||
{"hello world", 2},
|
||||
{" spaces everywhere ", 2},
|
||||
{"one\ttwo\nthree", 3},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := WordCount(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("WordCount(%q) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFTS5Query(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"", ""},
|
||||
{"hello", `"hello"`},
|
||||
{"hello world", `"hello" "world"`},
|
||||
{"to from and", `"to" "from" "and"`},
|
||||
{`"quoted"`, `"quoted"`},
|
||||
{`"quoted" unquoted`, `"quoted" "unquoted"`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := sanitizeFTS5Query(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("sanitizeFTS5Query(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package wiki
|
||||
|
||||
import "time"
|
||||
|
||||
// Article represents a wiki article with its current state.
|
||||
type Article struct {
|
||||
ID int64 `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
Revision int `json:"revision"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Computed fields
|
||||
WordCount int `json:"word_count,omitempty"`
|
||||
OutgoingLinks []ArticleLink `json:"outgoing_links,omitempty"`
|
||||
Backlinks []ArticleSummary `json:"backlinks,omitempty"`
|
||||
}
|
||||
|
||||
// ArticleSummary is a lightweight article representation for lists.
|
||||
type ArticleSummary struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Revision int `json:"revision"`
|
||||
WordCount int `json:"word_count"`
|
||||
}
|
||||
|
||||
// ArticleRevision represents a single revision of an article.
|
||||
type ArticleRevision struct {
|
||||
ID int64 `json:"id"`
|
||||
Revision int `json:"revision"`
|
||||
Body string `json:"body,omitempty"`
|
||||
ChangedBy string `json:"changed_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
WordCount int `json:"word_count"`
|
||||
}
|
||||
|
||||
// ArticleLink represents a [[backlink]] from one article to another.
|
||||
type ArticleLink struct {
|
||||
FromSlug string `json:"from_slug"`
|
||||
ToSlug string `json:"to_slug"`
|
||||
DisplayText string `json:"display_text,omitempty"`
|
||||
}
|
||||
|
||||
// MapOfContent provides a bird's-eye view of the wiki knowledge graph.
|
||||
type MapOfContent struct {
|
||||
Hubs []ArticleWithLinks `json:"hubs"`
|
||||
Articles []ArticleSummary `json:"articles"`
|
||||
Orphans []ArticleSummary `json:"orphans"`
|
||||
Wanted []WantedArticle `json:"wanted"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// ArticleWithLinks is a summary enriched with backlink count.
|
||||
type ArticleWithLinks struct {
|
||||
ArticleSummary
|
||||
BacklinkCount int `json:"backlink_count"`
|
||||
}
|
||||
|
||||
// WantedArticle is a slug referenced via [[slug]] but not yet created.
|
||||
type WantedArticle struct {
|
||||
Slug string `json:"slug"`
|
||||
ReferencedCount int `json:"referenced_count"`
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func setupEnv(t *testing.T) *testEnv {
|
||||
actionIndex := actions.NewIndex(actionRegistry.List())
|
||||
|
||||
// Create MCP server with 4 hybrid tools
|
||||
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attService, searchService, nil, nil, con, jsPool, actionRegistry, actionIndex, db)
|
||||
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attService, searchService, nil, nil, nil, con, jsPool, actionRegistry, actionIndex, db)
|
||||
t.Cleanup(func() {
|
||||
mcpSrv.Shutdown(context.Background())
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user