Ports the GraphMCP-Example substrate into lore-engine-poc: - 8 Go workers under workers/ (discord-connector, discord-filter, lore-watcher, ingestion-worker, entity-extractor, lore-extractor, encounter-processor, mcp-server), each with Dockerfile + go.mod - 3 Go unit-test files (encounter-processor, ingestion-worker, lore-extractor) — other 5 workers rely on integration tests via the live stack - plugins/nsc.py: thin httpx proxy from gateway to lore-mcp-server:9000, exposes all 11 inherited GraphMCP tools (input schemas verbatim from mcp-server/main.go) - docker-compose.yml: adds lore-redis + lore-mcp-server + the 7 worker services (lore- prefix to avoid clash with other GraphMCP stacks) - verify-merge.sh (171 LOC, 7 pass conditions) + docs/VERIFICATION.md - tests/contract/test_graphmcp_tool_contracts.py (15 tests; skipped when stack is down — TDD pattern, becomes active once docker compose up brings the stack) - README.md + test.sh updated for the merged service inventory Leader notes (2026-06-27 03:50): - Worker self-blocked review-required after 2 runs (run #7 hit 120/120 iteration budget; run #8 staged 40 files and reported shippable). - Tests are SKIPPED until docker compose up — worker chose that pattern over mocking (consistent with the lore-engine-poc project convention). To activate, run `docker compose up -d --build && pytest tests/contract/`. - File Scope reconciliation: story said gateway/plugins/nsc/__init__.py; worker shipped plugins/nsc.py (flat file). Justified by the existing plugins/ convention in lore-engine-poc (server.py glob("*.py")). A future PR could split nsc into a package once server.py learns __init__.py discovery. - nsc plugin exposes 11 tools (not 8) — the AC said "8" but the worker enumerated all 11 tools present in mcp-server/main.go. The encounter-specific 3 tools (list_encounters, search_encounters, get_encounter) were included for consistency. Story AC #2 reads "≥ 8 GraphMCP tools" so this exceeds AC. Refs: S2-phase-1-substrate-merge, milestone #64 P1 — Substrate merge
234 lines
5.0 KiB
Go
234 lines
5.0 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/fsnotify/fsnotify"
|
|
)
|
|
|
|
type Config struct {
|
|
WatchDir string
|
|
IngestURL string
|
|
DebounceMS int
|
|
}
|
|
|
|
func configFromEnv() Config {
|
|
ms := 500
|
|
if v := os.Getenv("DEBOUNCE_MS"); v != "" {
|
|
fmt.Sscan(v, &ms)
|
|
}
|
|
return Config{
|
|
WatchDir: getEnv("WATCH_DIR", "/data/lore"),
|
|
IngestURL: getEnv("INGEST_URL", "http://ingestion-worker:8080/ingest/lore"),
|
|
DebounceMS: ms,
|
|
}
|
|
}
|
|
|
|
func getEnv(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// stateFile is stored inside WatchDir so it persists across container restarts
|
|
// when the directory is a bind mount.
|
|
const stateFileName = ".lore-watcher-state.json"
|
|
|
|
type watchState struct {
|
|
Hashes map[string]string `json:"hashes"`
|
|
}
|
|
|
|
func loadState(dir string) watchState {
|
|
s := watchState{Hashes: make(map[string]string)}
|
|
data, err := os.ReadFile(filepath.Join(dir, stateFileName))
|
|
if err != nil {
|
|
return s
|
|
}
|
|
json.Unmarshal(data, &s)
|
|
return s
|
|
}
|
|
|
|
func saveState(dir string, s watchState) {
|
|
data, _ := json.Marshal(s)
|
|
os.WriteFile(filepath.Join(dir, stateFileName), data, 0644)
|
|
}
|
|
|
|
func hashFile(path string) (string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
h := sha256.New()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil)), nil
|
|
}
|
|
|
|
func shouldIgnore(path string) bool {
|
|
base := filepath.Base(path)
|
|
if strings.HasPrefix(base, ".") {
|
|
return true
|
|
}
|
|
for _, suffix := range []string{".swp", "~", ".tmp"} {
|
|
if strings.HasSuffix(base, suffix) {
|
|
return true
|
|
}
|
|
}
|
|
if base == "4913" { // vim temp file number
|
|
return true
|
|
}
|
|
return filepath.Ext(path) != ".md"
|
|
}
|
|
|
|
func upload(ingestURL, path string) error {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
body := &bytes.Buffer{}
|
|
w := multipart.NewWriter(body)
|
|
part, err := w.CreateFormFile("file", filepath.Base(path))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(part, f); err != nil {
|
|
return err
|
|
}
|
|
w.Close()
|
|
|
|
resp, err := http.Post(ingestURL, w.FormDataContentType(), body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("upload returned HTTP %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func processFile(cfg Config, s watchState, absPath string) {
|
|
if _, err := os.Stat(absPath); err != nil {
|
|
return // file may have been deleted before debounce fired
|
|
}
|
|
rel, _ := filepath.Rel(cfg.WatchDir, absPath)
|
|
|
|
hash, err := hashFile(absPath)
|
|
if err != nil {
|
|
slog.Error("hash failed", "path", rel, "err", err)
|
|
return
|
|
}
|
|
if existing, ok := s.Hashes[rel]; ok && existing == hash {
|
|
return // content unchanged since last upload
|
|
}
|
|
|
|
slog.Info("uploading", "file", rel)
|
|
if err := upload(cfg.IngestURL, absPath); err != nil {
|
|
slog.Error("upload failed", "file", rel, "err", err)
|
|
return
|
|
}
|
|
s.Hashes[rel] = hash
|
|
saveState(cfg.WatchDir, s)
|
|
slog.Info("uploaded", "file", rel)
|
|
}
|
|
|
|
func main() {
|
|
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
|
|
cfg := configFromEnv()
|
|
|
|
if _, err := os.Stat(cfg.WatchDir); err != nil {
|
|
slog.Error("watch directory not found", "dir", cfg.WatchDir, "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
s := loadState(cfg.WatchDir)
|
|
debounce := time.Duration(cfg.DebounceMS) * time.Millisecond
|
|
|
|
// Scan on startup — upload anything new or changed since last run
|
|
slog.Info("scanning directory", "dir", cfg.WatchDir)
|
|
filepath.Walk(cfg.WatchDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() || shouldIgnore(path) {
|
|
return nil
|
|
}
|
|
processFile(cfg, s, path)
|
|
return nil
|
|
})
|
|
|
|
watcher, err := fsnotify.NewWatcher()
|
|
if err != nil {
|
|
slog.Error("failed to create watcher", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
defer watcher.Close()
|
|
|
|
// Watch all subdirectories recursively
|
|
filepath.Walk(cfg.WatchDir, func(path string, info os.FileInfo, err error) error {
|
|
if err == nil && info.IsDir() {
|
|
watcher.Add(path)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
slog.Info("watching for changes", "dir", cfg.WatchDir, "debounce_ms", cfg.DebounceMS)
|
|
|
|
// Per-file debounce timers — noisy editors (vim, VS Code) fire multiple events per save
|
|
timers := make(map[string]*time.Timer)
|
|
|
|
for {
|
|
select {
|
|
case event, ok := <-watcher.Events:
|
|
if !ok {
|
|
return
|
|
}
|
|
path := event.Name
|
|
|
|
// Add newly created subdirectories to the watch set
|
|
if event.Op&fsnotify.Create != 0 {
|
|
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
|
watcher.Add(path)
|
|
continue
|
|
}
|
|
}
|
|
|
|
if shouldIgnore(path) {
|
|
continue
|
|
}
|
|
if event.Op&(fsnotify.Write|fsnotify.Create) == 0 {
|
|
continue
|
|
}
|
|
|
|
if t, ok := timers[path]; ok {
|
|
t.Stop()
|
|
}
|
|
p := path // capture for closure
|
|
timers[p] = time.AfterFunc(debounce, func() {
|
|
processFile(cfg, s, p)
|
|
delete(timers, p)
|
|
})
|
|
|
|
case err, ok := <-watcher.Errors:
|
|
if !ok {
|
|
return
|
|
}
|
|
slog.Error("watcher error", "err", err)
|
|
}
|
|
}
|
|
}
|