@m16khb/llm-wiki 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +170 -0
- package/cmd/llm-wiki/main.go +272 -0
- package/go.mod +23 -0
- package/go.sum +41 -0
- package/internal/daemon/lock.go +50 -0
- package/internal/daemon/lock_test.go +19 -0
- package/internal/daemon/server.go +133 -0
- package/internal/mcpserver/server.go +162 -0
- package/internal/service/service.go +217 -0
- package/internal/service/service_test.go +75 -0
- package/internal/store/store.go +245 -0
- package/internal/store/store_test.go +48 -0
- package/internal/wiki/capture.go +177 -0
- package/internal/wiki/frontmatter.go +158 -0
- package/internal/wiki/lint.go +91 -0
- package/internal/wiki/search.go +183 -0
- package/internal/wiki/vault.go +279 -0
- package/internal/wiki/vault_test.go +188 -0
- package/npm/bin/llm-wiki.js +2 -0
- package/npm/lib/runner.js +167 -0
- package/package.json +28 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
package daemon
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"log"
|
|
9
|
+
"net/http"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
13
|
+
|
|
14
|
+
"github.com/m16khb/llm-wiki/internal/mcpserver"
|
|
15
|
+
"github.com/m16khb/llm-wiki/internal/service"
|
|
16
|
+
"github.com/m16khb/llm-wiki/internal/store"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// Config configures the long-running daemon process.
|
|
20
|
+
type Config struct {
|
|
21
|
+
VaultRoot string
|
|
22
|
+
DBPath string
|
|
23
|
+
Addr string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Server owns the singleton daemon service and HTTP MCP endpoint.
|
|
27
|
+
type Server struct {
|
|
28
|
+
Service *service.Service
|
|
29
|
+
Addr string
|
|
30
|
+
DBPath string
|
|
31
|
+
Lock *Lock
|
|
32
|
+
HTTP *http.Server
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func New(cfg Config) (*Server, error) {
|
|
36
|
+
if cfg.Addr == "" {
|
|
37
|
+
cfg.Addr = "127.0.0.1:39233"
|
|
38
|
+
}
|
|
39
|
+
dbPath := cfg.DBPath
|
|
40
|
+
if dbPath == "" {
|
|
41
|
+
dbPath = store.DefaultPath()
|
|
42
|
+
}
|
|
43
|
+
lock, err := AcquireLock(dbPath + ".lock")
|
|
44
|
+
if err != nil {
|
|
45
|
+
return nil, err
|
|
46
|
+
}
|
|
47
|
+
svc, err := service.New(service.Config{VaultRoot: cfg.VaultRoot, DBPath: dbPath})
|
|
48
|
+
if err != nil {
|
|
49
|
+
lock.Close()
|
|
50
|
+
return nil, err
|
|
51
|
+
}
|
|
52
|
+
mux := NewHandler(svc)
|
|
53
|
+
httpServer := &http.Server{Addr: cfg.Addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
|
|
54
|
+
return &Server{Service: svc, Addr: cfg.Addr, DBPath: svc.DBPath(), Lock: lock, HTTP: httpServer}, nil
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func (s *Server) Serve(ctx context.Context) error {
|
|
58
|
+
if ctx == nil {
|
|
59
|
+
ctx = context.Background()
|
|
60
|
+
}
|
|
61
|
+
errCh := make(chan error, 1)
|
|
62
|
+
go func() {
|
|
63
|
+
log.Printf("llm-wiki daemon listening on http://%s/mcp vault=%s db=%s", s.Addr, s.Service.VaultRoot(), s.DBPath)
|
|
64
|
+
errCh <- s.HTTP.ListenAndServe()
|
|
65
|
+
}()
|
|
66
|
+
select {
|
|
67
|
+
case <-ctx.Done():
|
|
68
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
69
|
+
defer cancel()
|
|
70
|
+
_ = s.HTTP.Shutdown(shutdownCtx)
|
|
71
|
+
<-errCh
|
|
72
|
+
return ctx.Err()
|
|
73
|
+
case err := <-errCh:
|
|
74
|
+
if errors.Is(err, http.ErrServerClosed) {
|
|
75
|
+
return nil
|
|
76
|
+
}
|
|
77
|
+
return err
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func (s *Server) Close() error {
|
|
82
|
+
var err error
|
|
83
|
+
if s.HTTP != nil {
|
|
84
|
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
85
|
+
err = s.HTTP.Shutdown(ctx)
|
|
86
|
+
cancel()
|
|
87
|
+
}
|
|
88
|
+
if s.Service != nil {
|
|
89
|
+
if closeErr := s.Service.Close(); err == nil {
|
|
90
|
+
err = closeErr
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if s.Lock != nil {
|
|
94
|
+
if lockErr := s.Lock.Close(); err == nil {
|
|
95
|
+
err = lockErr
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return err
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func NewHandler(svc *service.Service) http.Handler {
|
|
102
|
+
server := mcpserver.NewWithBackend(svc)
|
|
103
|
+
mcpHandler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { return server }, &mcp.StreamableHTTPOptions{JSONResponse: true, SessionTimeout: 30 * time.Minute})
|
|
104
|
+
mux := http.NewServeMux()
|
|
105
|
+
mux.Handle("/mcp", mcpHandler)
|
|
106
|
+
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
107
|
+
if r.Method != http.MethodGet {
|
|
108
|
+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
info, err := svc.Info()
|
|
112
|
+
if err != nil {
|
|
113
|
+
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
w.Header().Set("Content-Type", "application/json")
|
|
117
|
+
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "vault": svc.VaultRoot(), "db": svc.DBPath(), "markdown_files": info.MarkdownFiles})
|
|
118
|
+
})
|
|
119
|
+
mux.HandleFunc("/jobs", func(w http.ResponseWriter, r *http.Request) {
|
|
120
|
+
if r.Method != http.MethodGet {
|
|
121
|
+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
jobs, err := svc.RecentJobs(r.Context(), 20)
|
|
125
|
+
if err != nil {
|
|
126
|
+
http.Error(w, fmt.Sprintf("jobs: %v", err), http.StatusInternalServerError)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
w.Header().Set("Content-Type", "application/json")
|
|
130
|
+
_ = json.NewEncoder(w).Encode(jobs)
|
|
131
|
+
})
|
|
132
|
+
return mux
|
|
133
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
package mcpserver
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
|
|
8
|
+
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
9
|
+
|
|
10
|
+
"github.com/m16khb/llm-wiki/internal/service"
|
|
11
|
+
"github.com/m16khb/llm-wiki/internal/wiki"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const Version = "0.1.0"
|
|
15
|
+
|
|
16
|
+
// Config configures the MCP server.
|
|
17
|
+
type Config struct {
|
|
18
|
+
VaultRoot string
|
|
19
|
+
DBPath string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type emptyInput struct{}
|
|
23
|
+
|
|
24
|
+
type infoOutput struct {
|
|
25
|
+
Info wiki.Info `json:"info"`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type searchInput struct {
|
|
29
|
+
Query string `json:"query" jsonschema:"keywords to search for"`
|
|
30
|
+
Limit int `json:"limit,omitempty" jsonschema:"maximum results, default 10, max 50"`
|
|
31
|
+
Roots []string `json:"roots,omitempty" jsonschema:"optional vault-relative folders such as 20-wiki or 10-sources"`
|
|
32
|
+
IncludeArchive bool `json:"include_archive,omitempty" jsonschema:"include _archive pages when true"`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type searchOutput struct {
|
|
36
|
+
Query string `json:"query"`
|
|
37
|
+
Results []wiki.SearchResult `json:"results"`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type readInput struct {
|
|
41
|
+
Page string `json:"page" jsonschema:"wikilink slug or vault-relative markdown path"`
|
|
42
|
+
MaxBytes int `json:"max_bytes,omitempty" jsonschema:"optional maximum bytes to return"`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type readOutput struct {
|
|
46
|
+
Page wiki.Page `json:"page"`
|
|
47
|
+
Content string `json:"content"`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type lintOutput struct {
|
|
51
|
+
Result wiki.LintResult `json:"result"`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type captureOutput struct {
|
|
55
|
+
Result wiki.CaptureResult `json:"result"`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Backend is the shared service surface used by MCP tools.
|
|
59
|
+
type Backend interface {
|
|
60
|
+
Info() (wiki.Info, error)
|
|
61
|
+
Search(wiki.SearchOptions) ([]wiki.SearchResult, error)
|
|
62
|
+
ReadPage(string, int) (wiki.Page, string, error)
|
|
63
|
+
Lint() (wiki.LintResult, error)
|
|
64
|
+
CaptureContext(context.Context, wiki.CaptureInput) (wiki.CaptureResult, error)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// New builds an MCP server exposing Obsidian-backed wiki tools backed by the
|
|
68
|
+
// SQLite queue service.
|
|
69
|
+
func New(cfg Config) (*mcp.Server, error) {
|
|
70
|
+
backend, err := service.New(service.Config{VaultRoot: cfg.VaultRoot, DBPath: cfg.DBPath})
|
|
71
|
+
if err != nil {
|
|
72
|
+
return nil, err
|
|
73
|
+
}
|
|
74
|
+
return NewWithBackend(backend), nil
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// NewWithBackend builds an MCP server around an existing daemon/service backend.
|
|
78
|
+
func NewWithBackend(backend Backend) *mcp.Server {
|
|
79
|
+
server := mcp.NewServer(&mcp.Implementation{Name: "llm-wiki", Version: Version}, nil)
|
|
80
|
+
registerTools(server, backend)
|
|
81
|
+
return server
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func registerTools(server *mcp.Server, backend Backend) {
|
|
85
|
+
closedWorld := false
|
|
86
|
+
addReadOnly := func(name, title, description string, handler any) {
|
|
87
|
+
switch h := handler.(type) {
|
|
88
|
+
case func(context.Context, *mcp.CallToolRequest, emptyInput) (*mcp.CallToolResult, infoOutput, error):
|
|
89
|
+
mcp.AddTool(server, &mcp.Tool{Name: name, Title: title, Description: description, Annotations: &mcp.ToolAnnotations{Title: title, ReadOnlyHint: true, OpenWorldHint: &closedWorld}}, h)
|
|
90
|
+
case func(context.Context, *mcp.CallToolRequest, searchInput) (*mcp.CallToolResult, searchOutput, error):
|
|
91
|
+
mcp.AddTool(server, &mcp.Tool{Name: name, Title: title, Description: description, Annotations: &mcp.ToolAnnotations{Title: title, ReadOnlyHint: true, OpenWorldHint: &closedWorld}}, h)
|
|
92
|
+
case func(context.Context, *mcp.CallToolRequest, readInput) (*mcp.CallToolResult, readOutput, error):
|
|
93
|
+
mcp.AddTool(server, &mcp.Tool{Name: name, Title: title, Description: description, Annotations: &mcp.ToolAnnotations{Title: title, ReadOnlyHint: true, OpenWorldHint: &closedWorld}}, h)
|
|
94
|
+
case func(context.Context, *mcp.CallToolRequest, emptyInput) (*mcp.CallToolResult, lintOutput, error):
|
|
95
|
+
mcp.AddTool(server, &mcp.Tool{Name: name, Title: title, Description: description, Annotations: &mcp.ToolAnnotations{Title: title, ReadOnlyHint: true, OpenWorldHint: &closedWorld}}, h)
|
|
96
|
+
default:
|
|
97
|
+
panic("unsupported handler type")
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
addReadOnly("wiki_info", "Wiki info", "Summarize the configured Obsidian LLM Wiki vault.",
|
|
102
|
+
func(ctx context.Context, req *mcp.CallToolRequest, in emptyInput) (*mcp.CallToolResult, infoOutput, error) {
|
|
103
|
+
info, err := backend.Info()
|
|
104
|
+
if err != nil {
|
|
105
|
+
return nil, infoOutput{}, err
|
|
106
|
+
}
|
|
107
|
+
return textResult(info), infoOutput{Info: info}, nil
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
addReadOnly("wiki_search", "Wiki search", "Search Markdown pages in the LLM Wiki by keyword.",
|
|
111
|
+
func(ctx context.Context, req *mcp.CallToolRequest, in searchInput) (*mcp.CallToolResult, searchOutput, error) {
|
|
112
|
+
results, err := backend.Search(wiki.SearchOptions{Query: in.Query, Limit: in.Limit, Roots: in.Roots, IncludeArchive: in.IncludeArchive})
|
|
113
|
+
if err != nil {
|
|
114
|
+
return nil, searchOutput{}, err
|
|
115
|
+
}
|
|
116
|
+
out := searchOutput{Query: in.Query, Results: results}
|
|
117
|
+
return textResult(out), out, nil
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
addReadOnly("wiki_read", "Wiki read", "Read a wiki page by wikilink slug or vault-relative Markdown path.",
|
|
121
|
+
func(ctx context.Context, req *mcp.CallToolRequest, in readInput) (*mcp.CallToolResult, readOutput, error) {
|
|
122
|
+
page, content, err := backend.ReadPage(in.Page, in.MaxBytes)
|
|
123
|
+
if err != nil {
|
|
124
|
+
return nil, readOutput{}, err
|
|
125
|
+
}
|
|
126
|
+
out := readOutput{Page: page, Content: content}
|
|
127
|
+
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: content}}}, out, nil
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
addReadOnly("wiki_lint", "Wiki lint", "Run structural lint checks for frontmatter, duplicate slugs, and broken wikilinks.",
|
|
131
|
+
func(ctx context.Context, req *mcp.CallToolRequest, in emptyInput) (*mcp.CallToolResult, lintOutput, error) {
|
|
132
|
+
result, err := backend.Lint()
|
|
133
|
+
if err != nil {
|
|
134
|
+
return nil, lintOutput{}, err
|
|
135
|
+
}
|
|
136
|
+
out := lintOutput{Result: result}
|
|
137
|
+
return textResult(out), out, nil
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
destructive := false
|
|
141
|
+
mcp.AddTool(server, &mcp.Tool{
|
|
142
|
+
Name: "wiki_capture",
|
|
143
|
+
Title: "Wiki capture",
|
|
144
|
+
Description: "Create or overwrite a curated Markdown note in allowed LLM Wiki folders. Does not write 10-sources or .obsidian.",
|
|
145
|
+
Annotations: &mcp.ToolAnnotations{Title: "Wiki capture", ReadOnlyHint: false, DestructiveHint: &destructive, IdempotentHint: false, OpenWorldHint: &closedWorld},
|
|
146
|
+
}, func(ctx context.Context, req *mcp.CallToolRequest, in wiki.CaptureInput) (*mcp.CallToolResult, captureOutput, error) {
|
|
147
|
+
result, err := backend.CaptureContext(ctx, in)
|
|
148
|
+
if err != nil {
|
|
149
|
+
return nil, captureOutput{}, err
|
|
150
|
+
}
|
|
151
|
+
out := captureOutput{Result: result}
|
|
152
|
+
return textResult(out), out, nil
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func textResult(v any) *mcp.CallToolResult {
|
|
157
|
+
data, err := json.MarshalIndent(v, "", " ")
|
|
158
|
+
if err != nil {
|
|
159
|
+
data = []byte(fmt.Sprint(v))
|
|
160
|
+
}
|
|
161
|
+
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(data)}}}
|
|
162
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
package service
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"sync"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/m16khb/llm-wiki/internal/store"
|
|
11
|
+
"github.com/m16khb/llm-wiki/internal/wiki"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// Config wires the daemon/service layer.
|
|
15
|
+
type Config struct {
|
|
16
|
+
VaultRoot string
|
|
17
|
+
DBPath string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Service is the shared daemon backend. All writes go through a single worker
|
|
21
|
+
// queue while reads share a small in-memory metadata cache.
|
|
22
|
+
type Service struct {
|
|
23
|
+
vault *wiki.Vault
|
|
24
|
+
store *store.Store
|
|
25
|
+
|
|
26
|
+
mu sync.RWMutex
|
|
27
|
+
jobs chan captureJob
|
|
28
|
+
done chan struct{}
|
|
29
|
+
|
|
30
|
+
cacheMu sync.Mutex
|
|
31
|
+
cache infoCache
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type infoCache struct {
|
|
35
|
+
info wiki.Info
|
|
36
|
+
loadedAt time.Time
|
|
37
|
+
valid bool
|
|
38
|
+
expiresIn time.Duration
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type captureJob struct {
|
|
42
|
+
id int64
|
|
43
|
+
input wiki.CaptureInput
|
|
44
|
+
done chan captureResult
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type captureResult struct {
|
|
48
|
+
result wiki.CaptureResult
|
|
49
|
+
err error
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// New creates a service with a SQLite WAL queue and a single write worker.
|
|
53
|
+
func New(cfg Config) (*Service, error) {
|
|
54
|
+
vault, err := wiki.New(cfg.VaultRoot)
|
|
55
|
+
if err != nil {
|
|
56
|
+
return nil, err
|
|
57
|
+
}
|
|
58
|
+
st, err := store.Open(cfg.DBPath)
|
|
59
|
+
if err != nil {
|
|
60
|
+
return nil, err
|
|
61
|
+
}
|
|
62
|
+
s := &Service{
|
|
63
|
+
vault: vault,
|
|
64
|
+
store: st,
|
|
65
|
+
jobs: make(chan captureJob, 128),
|
|
66
|
+
done: make(chan struct{}),
|
|
67
|
+
}
|
|
68
|
+
s.cache.expiresIn = 2 * time.Second
|
|
69
|
+
go s.captureWorker()
|
|
70
|
+
return s, nil
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func (s *Service) Close() error {
|
|
74
|
+
if s == nil {
|
|
75
|
+
return nil
|
|
76
|
+
}
|
|
77
|
+
select {
|
|
78
|
+
case <-s.done:
|
|
79
|
+
default:
|
|
80
|
+
close(s.done)
|
|
81
|
+
}
|
|
82
|
+
if s.store != nil {
|
|
83
|
+
return s.store.Close()
|
|
84
|
+
}
|
|
85
|
+
return nil
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func (s *Service) VaultRoot() string {
|
|
89
|
+
return s.vault.Root
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func (s *Service) DBPath() string {
|
|
93
|
+
return s.store.Path
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func (s *Service) Info() (wiki.Info, error) {
|
|
97
|
+
s.cacheMu.Lock()
|
|
98
|
+
if s.cache.valid && time.Since(s.cache.loadedAt) < s.cache.expiresIn {
|
|
99
|
+
info := s.cache.info
|
|
100
|
+
s.cacheMu.Unlock()
|
|
101
|
+
return info, nil
|
|
102
|
+
}
|
|
103
|
+
s.cacheMu.Unlock()
|
|
104
|
+
|
|
105
|
+
s.mu.RLock()
|
|
106
|
+
info, err := s.vault.Info()
|
|
107
|
+
s.mu.RUnlock()
|
|
108
|
+
if err != nil {
|
|
109
|
+
return wiki.Info{}, err
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
s.cacheMu.Lock()
|
|
113
|
+
s.cache.info = info
|
|
114
|
+
s.cache.loadedAt = time.Now()
|
|
115
|
+
s.cache.valid = true
|
|
116
|
+
s.cacheMu.Unlock()
|
|
117
|
+
return info, nil
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func (s *Service) Search(opts wiki.SearchOptions) ([]wiki.SearchResult, error) {
|
|
121
|
+
s.mu.RLock()
|
|
122
|
+
defer s.mu.RUnlock()
|
|
123
|
+
return s.vault.Search(opts)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func (s *Service) ReadPage(ref string, maxBytes int) (wiki.Page, string, error) {
|
|
127
|
+
s.mu.RLock()
|
|
128
|
+
defer s.mu.RUnlock()
|
|
129
|
+
return s.vault.ReadPage(ref, maxBytes)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
func (s *Service) Lint() (wiki.LintResult, error) {
|
|
133
|
+
s.mu.RLock()
|
|
134
|
+
defer s.mu.RUnlock()
|
|
135
|
+
return s.vault.Lint()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func (s *Service) Capture(in wiki.CaptureInput) (wiki.CaptureResult, error) {
|
|
139
|
+
return s.CaptureContext(context.Background(), in)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// CaptureContext durably enqueues a capture job and waits for the daemon's
|
|
143
|
+
// single writer to complete it.
|
|
144
|
+
func (s *Service) CaptureContext(ctx context.Context, in wiki.CaptureInput) (wiki.CaptureResult, error) {
|
|
145
|
+
if ctx == nil {
|
|
146
|
+
ctx = context.Background()
|
|
147
|
+
}
|
|
148
|
+
id, err := s.store.Enqueue(ctx, "wiki_capture", in)
|
|
149
|
+
if err != nil {
|
|
150
|
+
return wiki.CaptureResult{}, err
|
|
151
|
+
}
|
|
152
|
+
job := captureJob{id: id, input: in, done: make(chan captureResult, 1)}
|
|
153
|
+
select {
|
|
154
|
+
case s.jobs <- job:
|
|
155
|
+
case <-ctx.Done():
|
|
156
|
+
_ = s.store.MarkFailed(context.Background(), id, ctx.Err())
|
|
157
|
+
return wiki.CaptureResult{}, ctx.Err()
|
|
158
|
+
case <-s.done:
|
|
159
|
+
err := errors.New("service is closed")
|
|
160
|
+
_ = s.store.MarkFailed(context.Background(), id, err)
|
|
161
|
+
return wiki.CaptureResult{}, err
|
|
162
|
+
}
|
|
163
|
+
select {
|
|
164
|
+
case res := <-job.done:
|
|
165
|
+
return res.result, res.err
|
|
166
|
+
case <-ctx.Done():
|
|
167
|
+
return wiki.CaptureResult{}, ctx.Err()
|
|
168
|
+
case <-s.done:
|
|
169
|
+
return wiki.CaptureResult{}, errors.New("service is closed")
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
func (s *Service) RecentJobs(ctx context.Context, limit int) ([]store.Job, error) {
|
|
174
|
+
return s.store.RecentJobs(ctx, limit)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
func (s *Service) captureWorker() {
|
|
178
|
+
for {
|
|
179
|
+
select {
|
|
180
|
+
case <-s.done:
|
|
181
|
+
return
|
|
182
|
+
case job := <-s.jobs:
|
|
183
|
+
s.runCaptureJob(job)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
func (s *Service) runCaptureJob(job captureJob) {
|
|
189
|
+
ctx := context.Background()
|
|
190
|
+
if err := s.store.MarkRunning(ctx, job.id); err != nil {
|
|
191
|
+
job.done <- captureResult{err: err}
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
s.mu.Lock()
|
|
195
|
+
result, err := s.vault.Capture(job.input)
|
|
196
|
+
s.mu.Unlock()
|
|
197
|
+
if err != nil {
|
|
198
|
+
markErr := s.store.MarkFailed(ctx, job.id, err)
|
|
199
|
+
if markErr != nil {
|
|
200
|
+
err = fmt.Errorf("%w; additionally failed to mark job failed: %v", err, markErr)
|
|
201
|
+
}
|
|
202
|
+
job.done <- captureResult{err: err}
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
s.invalidateInfoCache()
|
|
206
|
+
if err := s.store.MarkCompleted(ctx, job.id, result); err != nil {
|
|
207
|
+
job.done <- captureResult{result: result, err: err}
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
job.done <- captureResult{result: result}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
func (s *Service) invalidateInfoCache() {
|
|
214
|
+
s.cacheMu.Lock()
|
|
215
|
+
s.cache.valid = false
|
|
216
|
+
s.cacheMu.Unlock()
|
|
217
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
package service
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"sync"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/m16khb/llm-wiki/internal/store"
|
|
11
|
+
"github.com/m16khb/llm-wiki/internal/wiki"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func TestServiceCaptureSerializesThroughSQLiteQueue(t *testing.T) {
|
|
15
|
+
tmp := t.TempDir()
|
|
16
|
+
s, err := New(Config{VaultRoot: tmp, DBPath: filepath.Join(tmp, "queue.db")})
|
|
17
|
+
if err != nil {
|
|
18
|
+
t.Fatalf("New() error = %v", err)
|
|
19
|
+
}
|
|
20
|
+
defer s.Close()
|
|
21
|
+
s.vault.Now = func() time.Time { return time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC) }
|
|
22
|
+
|
|
23
|
+
const n = 8
|
|
24
|
+
var wg sync.WaitGroup
|
|
25
|
+
errs := make(chan error, n)
|
|
26
|
+
for i := 0; i < n; i++ {
|
|
27
|
+
i := i
|
|
28
|
+
wg.Add(1)
|
|
29
|
+
go func() {
|
|
30
|
+
defer wg.Done()
|
|
31
|
+
_, err := s.Capture(wiki.CaptureInput{Title: "Note", Body: "body", Slug: "note-" + string(rune('a'+i))})
|
|
32
|
+
errs <- err
|
|
33
|
+
}()
|
|
34
|
+
}
|
|
35
|
+
wg.Wait()
|
|
36
|
+
close(errs)
|
|
37
|
+
for err := range errs {
|
|
38
|
+
if err != nil {
|
|
39
|
+
t.Fatalf("Capture() error = %v", err)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
jobs, err := s.RecentJobs(context.Background(), 20)
|
|
44
|
+
if err != nil {
|
|
45
|
+
t.Fatalf("RecentJobs() error = %v", err)
|
|
46
|
+
}
|
|
47
|
+
if len(jobs) != n {
|
|
48
|
+
t.Fatalf("got %d jobs, want %d: %+v", len(jobs), n, jobs)
|
|
49
|
+
}
|
|
50
|
+
for _, job := range jobs {
|
|
51
|
+
if job.Status != store.StatusCompleted {
|
|
52
|
+
t.Fatalf("job not completed: %+v", job)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func TestServiceCaptureAuditsFailedJob(t *testing.T) {
|
|
58
|
+
tmp := t.TempDir()
|
|
59
|
+
s, err := New(Config{VaultRoot: tmp, DBPath: filepath.Join(tmp, "queue.db")})
|
|
60
|
+
if err != nil {
|
|
61
|
+
t.Fatalf("New() error = %v", err)
|
|
62
|
+
}
|
|
63
|
+
defer s.Close()
|
|
64
|
+
|
|
65
|
+
if _, err := s.Capture(wiki.CaptureInput{Title: "Bad", Body: "body", Folder: "10-sources", Slug: "bad"}); err == nil {
|
|
66
|
+
t.Fatalf("expected protected folder capture to fail")
|
|
67
|
+
}
|
|
68
|
+
jobs, err := s.RecentJobs(context.Background(), 1)
|
|
69
|
+
if err != nil {
|
|
70
|
+
t.Fatalf("RecentJobs() error = %v", err)
|
|
71
|
+
}
|
|
72
|
+
if len(jobs) != 1 || jobs[0].Status != store.StatusFailed || jobs[0].Error == "" {
|
|
73
|
+
t.Fatalf("expected failed audited job, got %+v", jobs)
|
|
74
|
+
}
|
|
75
|
+
}
|