Port of the load-bearing pieces of mcp-server/observer.ts (Rust
system, 852 lines TS) per SPEC §2's named target. Implements PRD
loop 3 ("Observer loop — watches each run, refines configs").
Routes (all under /v1/observer/* via gateway):
GET /observer/health — liveness
GET /observer/stats — total / successes / failures /
by_source / recent_scenario_ops
(matches Rust JSON shape exactly)
POST /observer/event — record one ObservedOp; auto-defaults
timestamp + source, validates required
fields (endpoint), persists to JSONL,
appends to ring buffer
Architecture:
- internal/observer/types.go — ObservedOp model + Source taxonomy
(mcp / scenario / langfuse / overseer_correction). Mirrors the
Rust shape so JSON round-trips during cutover.
- internal/observer/store.go — Store + Persistor. Ring buffer cap
matches Rust's 2000; recent_scenarios cap matches Rust's 10.
Same persist-then-apply order as pathwayd; same corruption-
tolerant replay (skip malformed lines + warn).
- cmd/observerd — :3219 HTTP service, fronted by gateway as
/v1/observer/*.
- lakehouse.toml + DefaultConfig — [observerd] block matches the
pathwayd pattern (Bind + PersistPath; empty path = ephemeral).
Tests + smoke (all PASS):
- 7 unit tests in store_test.go: validation, default fields,
stats aggregation, recent-scenarios cap + ordering, ring-buffer
rollover at cap, JSONL round-trip persistence, corruption-
tolerant replay (1 valid + 1 corrupt + 1 valid → 2 applied)
- scripts/observer_smoke.sh: 4 assertions through gateway —
record 5 events (3 ok / 2 fail across 2 sources), stats
aggregates correctly, empty-endpoint→400, kill+restart preserves
via JSONL replay (5 ops, 3 ok, 2 err survive)
Deferred (named in package + cmd doc, not in this commit):
- POST /observer/review (cloud-LLM hand-review fall-back). The
heuristic-only path could land cheaply but the productized
cloud path (qwen3-coder fall-back) is multi-day port.
- Background loops: analyzeErrors, consolidatePlaybooks,
tailOverseerCorrections (read overseer_corrections.jsonl into
the ring buffer once per cycle).
- escalateFailureClusterToLLMTeam (failure clustering trigger
that posts to LLM Team's /api/run with code_review mode).
/relevance is NOT duplicated — already ported in 9588bd8 to
internal/matrix/relevance.go (component 3 of SPEC §3.4).
16-smoke regression all green (D1-D6, G1, G1P, G2, storaged_cap,
pathway, matrix, relevance, downgrade, playbook, observer).
13 binaries now: gateway, storaged, catalogd, ingestd, queryd,
vectord, embedd, pathwayd, matrixd, observerd, mcpd, fake_ollama
(plus catalogd-only test build).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
128 lines
4.8 KiB
Go
128 lines
4.8 KiB
Go
// gateway is the Lakehouse-Go HTTP ingress. D6 promotes the D1
|
|
// stub endpoints into real reverse-proxies fronting all four backing
|
|
// services (storaged, catalogd, ingestd, queryd) on a single bind.
|
|
//
|
|
// Routes:
|
|
// /v1/storage/* → storaged
|
|
// /v1/catalog/* → catalogd
|
|
// /v1/ingest → ingestd
|
|
// /v1/sql → queryd
|
|
//
|
|
// The /v1 prefix lives at the edge — internal services route on
|
|
// /storage, /catalog, /ingest, /sql. Per Kimi K2 finding from the
|
|
// D1 plan review: httputil.NewSingleHostReverseProxy preserves the
|
|
// inbound path by default, so the proxy helper strips /v1 in its
|
|
// Director before forwarding.
|
|
package main
|
|
|
|
import (
|
|
"flag"
|
|
"log/slog"
|
|
"net/url"
|
|
"os"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/gateway"
|
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/shared"
|
|
)
|
|
|
|
func main() {
|
|
configPath := flag.String("config", "lakehouse.toml", "path to TOML config")
|
|
flag.Parse()
|
|
|
|
cfg, err := shared.LoadConfig(*configPath)
|
|
if err != nil {
|
|
slog.Error("config", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
upstreams := map[string]string{
|
|
"storaged_url": cfg.Gateway.StoragedURL,
|
|
"catalogd_url": cfg.Gateway.CatalogdURL,
|
|
"ingestd_url": cfg.Gateway.IngestdURL,
|
|
"queryd_url": cfg.Gateway.QuerydURL,
|
|
"vectord_url": cfg.Gateway.VectordURL,
|
|
"embedd_url": cfg.Gateway.EmbeddURL,
|
|
"pathwayd_url": cfg.Gateway.PathwaydURL,
|
|
"matrixd_url": cfg.Gateway.MatrixdURL,
|
|
"observerd_url": cfg.Gateway.ObserverdURL,
|
|
}
|
|
for k, v := range upstreams {
|
|
if v == "" {
|
|
slog.Error("config", "err", "gateway."+k+" is required")
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// Per scrum O-WARN2 (Opus): url.Parse is permissive — a typo
|
|
// like "127.0.0.1:3211" (missing scheme) parses without error
|
|
// but produces empty Host, and every proxied request 502s. Fail
|
|
// fast at startup if scheme/host are missing so misconfigs
|
|
// surface in `systemctl status gateway` rather than at first traffic.
|
|
storagedURL := mustParseUpstream("storaged_url", cfg.Gateway.StoragedURL)
|
|
catalogdURL := mustParseUpstream("catalogd_url", cfg.Gateway.CatalogdURL)
|
|
ingestdURL := mustParseUpstream("ingestd_url", cfg.Gateway.IngestdURL)
|
|
querydURL := mustParseUpstream("queryd_url", cfg.Gateway.QuerydURL)
|
|
vectordURL := mustParseUpstream("vectord_url", cfg.Gateway.VectordURL)
|
|
embeddURL := mustParseUpstream("embedd_url", cfg.Gateway.EmbeddURL)
|
|
pathwaydURL := mustParseUpstream("pathwayd_url", cfg.Gateway.PathwaydURL)
|
|
matrixdURL := mustParseUpstream("matrixd_url", cfg.Gateway.MatrixdURL)
|
|
observerdURL := mustParseUpstream("observerd_url", cfg.Gateway.ObserverdURL)
|
|
|
|
storagedProxy := gateway.NewProxyHandler(storagedURL)
|
|
catalogdProxy := gateway.NewProxyHandler(catalogdURL)
|
|
ingestdProxy := gateway.NewProxyHandler(ingestdURL)
|
|
querydProxy := gateway.NewProxyHandler(querydURL)
|
|
vectordProxy := gateway.NewProxyHandler(vectordURL)
|
|
embeddProxy := gateway.NewProxyHandler(embeddURL)
|
|
pathwaydProxy := gateway.NewProxyHandler(pathwaydURL)
|
|
matrixdProxy := gateway.NewProxyHandler(matrixdURL)
|
|
observerdProxy := gateway.NewProxyHandler(observerdURL)
|
|
|
|
if err := shared.Run("gateway", cfg.Gateway.Bind, func(r chi.Router) {
|
|
|
|
// Storage / catalog have multi-segment paths under their
|
|
// prefix (e.g. /v1/storage/get/<key>). chi's `*` wildcard
|
|
// captures the rest of the path.
|
|
r.Handle("/v1/storage/*", storagedProxy)
|
|
r.Handle("/v1/catalog/*", catalogdProxy)
|
|
// Ingest + sql are single endpoints. We accept any method
|
|
// (GET/POST/etc) and let the backing service decide. ingestd
|
|
// only accepts POST; queryd only accepts POST. Other methods
|
|
// will get the backend's 405.
|
|
r.Handle("/v1/ingest", ingestdProxy)
|
|
r.Handle("/v1/sql", querydProxy)
|
|
// Vector search routes — /v1/vectors/index, /v1/vectors/index/{name}/...
|
|
r.Handle("/v1/vectors/*", vectordProxy)
|
|
// Embedding service — /v1/embed
|
|
r.Handle("/v1/embed", embeddProxy)
|
|
// Pathway memory — /v1/pathway/*
|
|
r.Handle("/v1/pathway/*", pathwaydProxy)
|
|
// Matrix indexer — /v1/matrix/* (multi-corpus retrieve+merge per SPEC §3.4)
|
|
r.Handle("/v1/matrix/*", matrixdProxy)
|
|
// Observer — /v1/observer/* (autonomous-iteration witness loop)
|
|
r.Handle("/v1/observer/*", observerdProxy)
|
|
}, cfg.Auth); err != nil {
|
|
slog.Error("server", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// mustParseUpstream parses an upstream URL string and validates that
|
|
// scheme + host are non-empty. Exits the process on failure — gateway
|
|
// can't function without a valid upstream so failing fast is the
|
|
// right call. Per scrum O-WARN2.
|
|
func mustParseUpstream(name, raw string) *url.URL {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
slog.Error("config", "err", "parse "+name+": "+err.Error())
|
|
os.Exit(1)
|
|
}
|
|
if u.Scheme == "" || u.Host == "" {
|
|
slog.Error("config", "err", name+" must include scheme + host (got "+raw+")")
|
|
os.Exit(1)
|
|
}
|
|
return u
|
|
}
|