feat(memory-continuity): read-only /v1/memory/* HTTP surface over the store seam
Endpoints: /v1/memory/status (honest lane status), /v1/memory/read (exact-by-id verbatim read with digest fixity), /v1/memory/continuity (active topic + working set as typed contribution blocks + bounded transition trail), /v1/memory/checkpoint (read-only capsule peek), /v1/health. GET/HEAD only — writes stay on the Python oracle (src/rune/memory_continuity.py) until parity soak; non-read verbs answer 405 read_only. Adds PeekCheckpoint/AllTopicStates read-only registry helpers and 'serve <addr> <db> [dir]' mode. rune-core runecore server.go envelope precedent. go test -race green (7 tests), vet/gofmt clean.
This commit is contained in:
1 parent
5398f9b522
commit
fd8dd9bfce
4 files changed
+432
-7
No files matched your search
@@ -594,6 +594,33 @@ func (c *TopicContinuity) Transitions() []Transition {
|
||||
return out
|
||||
}
|
||||
|
||||
// AllTopicStates — every topic the registry knows, attention state
|
||||
// included, for the read surface. Unknown topics are absent (never
|
||||
// fabricated); the map is a copy.
|
||||
func (c *TopicContinuity) AllTopicStates() map[string]TopicState {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := make(map[string]TopicState, len(c.states))
|
||||
for k, v := range c.states {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PeekCheckpoint — a READ-ONLY checkpoint peek: the durable capsule
|
||||
// verbatim, or the typed failure receipt (NOT_FOUND_CONFIRMED /
|
||||
// SOURCE_CORRUPT). Unlike Rehydrate it enters no topic, advances no
|
||||
// state, and mutates nothing — the read surface's honest window on
|
||||
// cooled topics.
|
||||
func (c *TopicContinuity) PeekCheckpoint(topicID string) (*Checkpoint, *RehydrateReceipt) {
|
||||
cp, receipt := c.loadCheckpoint(topicID)
|
||||
if receipt != nil {
|
||||
receipt.TopicID = topicID
|
||||
return nil, receipt
|
||||
}
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
func (c *TopicContinuity) recordTransitionLocked(frm, to, reason string) {
|
||||
c.transitions = append(c.transitions,
|
||||
Transition{From: frm, To: to, Reason: reason, At: nowStamp()})
|
||||
@@ -876,12 +903,12 @@ func (c *TopicContinuity) WorkingSet() []WorkingEntry {
|
||||
// ContributionBlock is the ContextBroker block shape (parity with
|
||||
// context_broker.ContributionBlock as consumed by the registered contributor).
|
||||
type ContributionBlock struct {
|
||||
Name string
|
||||
Priority int
|
||||
Content string
|
||||
Tokens int
|
||||
Source string
|
||||
Evictable bool
|
||||
Name string `json:"name"`
|
||||
Priority int `json:"priority"`
|
||||
Content string `json:"content"`
|
||||
Tokens int `json:"tokens"`
|
||||
Source string `json:"source"`
|
||||
Evictable bool `json:"evictable"`
|
||||
}
|
||||
|
||||
// ContinuityBlocks materializes a working set as typed ContributionBlocks for
|
||||
|
||||
@@ -4,12 +4,13 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintln(os.Stderr, "usage: memory-continuity <status|read> <db> [entry-id] [expected-digest]")
|
||||
fmt.Fprintln(os.Stderr, "usage: memory-continuity <status|read|serve> <db-or-addr> [entry-id] [digest-or-dir]")
|
||||
os.Exit(2)
|
||||
}
|
||||
cmd, dbPath := os.Args[1], os.Args[2]
|
||||
@@ -19,6 +20,22 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
if cmd == "serve" {
|
||||
// serve <addr> <db> [continuity-dir] — the read-only /v1/memory/*
|
||||
// surface for the TUI's memory/continuity feeds.
|
||||
addr := os.Args[2]
|
||||
dir := ".continuity"
|
||||
if len(os.Args) > 4 {
|
||||
dir = os.Args[4]
|
||||
}
|
||||
srv := NewServer(db, NewTopicContinuity(dir))
|
||||
fmt.Fprintf(os.Stderr, "memory-continuity reading on http://%s\n", addr)
|
||||
if err := http.ListenAndServe(addr, srv.Handler()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "serve: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
switch cmd {
|
||||
case "status":
|
||||
ms := MemoryStatus(db)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
// server.go — the read-only HTTP surface over the continuity store and
|
||||
// topic registry (rune-core internal/runecore server.go precedent: same
|
||||
// error envelope {"error":{"code","message"}}, honest typed statuses).
|
||||
// READ-ONLY for this wave: writes stay on the Python oracle
|
||||
// (src/rune/memory_continuity.py) until parity soak — no POST/PUT/DELETE
|
||||
// route exists here by construction, and the handler method gate makes
|
||||
// one a 405 rather than an accident.
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Server is the memory-continuity read surface over one open store and
|
||||
// one topic continuity registry.
|
||||
type Server struct {
|
||||
db *sql.DB
|
||||
tc *TopicContinuity
|
||||
|
||||
started string
|
||||
identity string
|
||||
}
|
||||
|
||||
// NewServer builds the surface.
|
||||
func NewServer(db *sql.DB, tc *TopicContinuity) *Server {
|
||||
return &Server{db: db, tc: tc, identity: "memory-continuity", started: NowISO()}
|
||||
}
|
||||
|
||||
// Handler mounts every route.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
read := func(path string, h http.HandlerFunc) {
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != path {
|
||||
errorEnvelope(w, http.StatusNotFound, "not_found", "no route "+r.URL.Path)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
errorEnvelope(w, http.StatusMethodNotAllowed, "read_only",
|
||||
"memory-continuity serves reads only this wave; writes stay on the Python oracle")
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
})
|
||||
}
|
||||
read("/v1/memory/status", s.handleStatus)
|
||||
read("/v1/memory/read", s.handleRead)
|
||||
read("/v1/memory/continuity", s.handleContinuity)
|
||||
read("/v1/memory/checkpoint", s.handleCheckpoint)
|
||||
read("/v1/health", s.handleHealth)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
errorEnvelope(w, http.StatusNotFound, "not_found", "no route "+r.URL.Path)
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
// errorEnvelope mirrors runecore's error envelope exactly.
|
||||
func errorEnvelope(w http.ResponseWriter, status int, code, message string) {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"error": map[string]string{"code": code, "message": message},
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
errorEnvelope(w, http.StatusInternalServerError, "internal_error", err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
// handleStatus: honest memory status (ready/degraded/unavailable with
|
||||
// per-lane detail) — the same typed result the CLI prints.
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, MemoryStatus(s.db))
|
||||
}
|
||||
|
||||
// handleRead: exact-by-ID verbatim read with optional expected digest.
|
||||
// - /v1/memory/read?id=42
|
||||
// - /v1/memory/read?id=42&digest=sha256:...
|
||||
//
|
||||
// A missing/invalid id is a 400 envelope; the typed statuses
|
||||
// (FOUND_VERIFIED / FOUND_UNVERIFIED / SOURCE_CORRUPT /
|
||||
// NOT_FOUND_CONFIRMED) ride a 200 so the TUI can render them directly.
|
||||
func (s *Server) handleRead(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.URL.Query().Get("id")
|
||||
if idStr == "" {
|
||||
errorEnvelope(w, http.StatusBadRequest, "bad_request", "missing id parameter")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
errorEnvelope(w, http.StatusBadRequest, "bad_request", "invalid id: "+idStr)
|
||||
return
|
||||
}
|
||||
var digest *string
|
||||
if d := r.URL.Query().Get("digest"); d != "" {
|
||||
digest = &d
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ReadByID(s.db, id, digest))
|
||||
}
|
||||
|
||||
// handleContinuity: the topic-continuity block feed — the active topic
|
||||
// (if any), its live working set materialized as typed contribution
|
||||
// blocks (the ContextBroker block shape the TUI's memory pane consumes),
|
||||
// the bounded transition trail, and every known topic's attention state.
|
||||
func (s *Server) handleContinuity(w http.ResponseWriter, r *http.Request) {
|
||||
active, hasActive := s.tc.ActiveTopic()
|
||||
working := s.tc.WorkingSet()
|
||||
blocks := ContinuityBlocks(working)
|
||||
if blocks == nil {
|
||||
blocks = []ContributionBlock{}
|
||||
}
|
||||
states := s.tc.AllTopicStates()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"identity": s.identity,
|
||||
"active_topic": active,
|
||||
"has_active": hasActive,
|
||||
"working_set": working,
|
||||
"blocks": blocks,
|
||||
"transitions": s.tc.Transitions(),
|
||||
"topic_states": states,
|
||||
"checkpoint_dir": s.tc.Dir(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleCheckpoint: a read-only checkpoint peek for one topic
|
||||
// (?topic=...) — the durable resume capsule verbatim, without touching
|
||||
// the live registry (Rehydrate is a state transition, not a read).
|
||||
// Unknown topics answer the typed NOT_FOUND_CONFIRMED receipt; the
|
||||
// route never fabricates continuity.
|
||||
func (s *Server) handleCheckpoint(w http.ResponseWriter, r *http.Request) {
|
||||
topic := strings.TrimSpace(r.URL.Query().Get("topic"))
|
||||
if topic == "" {
|
||||
errorEnvelope(w, http.StatusBadRequest, "bad_request", "missing topic parameter")
|
||||
return
|
||||
}
|
||||
cp, receipt := s.tc.PeekCheckpoint(topic)
|
||||
if receipt != nil {
|
||||
writeJSON(w, http.StatusOK, receipt)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cp)
|
||||
}
|
||||
|
||||
// handleHealth: liveness with identity + start stamp.
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"identity": s.identity,
|
||||
"status": "ok",
|
||||
"started": s.started,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
db, err := OpenStore(filepath.Join(t.TempDir(), "mem.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if _, err := db.Exec(
|
||||
"INSERT INTO entries (text, created_at, status) VALUES ('owner preferstyped statuses', '2026-10-21T00:00:00+00:00', 'active')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
tc := NewTopicContinuity(dir)
|
||||
ts := httptest.NewServer(NewServer(db, tc).Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
func get(t *testing.T, url string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp.StatusCode, body
|
||||
}
|
||||
|
||||
func TestMemoryStatusEndpoint(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
code, body := get(t, ts.URL+"/v1/memory/status")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status: got %d", code)
|
||||
}
|
||||
if body["status"] != "ready" && body["status"] != "degraded" {
|
||||
t.Fatalf("honest overall status, got %v", body["status"])
|
||||
}
|
||||
if body["active_entries"].(float64) != 1 {
|
||||
t.Fatalf("active_entries, got %v", body["active_entries"])
|
||||
}
|
||||
lanes, ok := body["lanes"].(map[string]any)
|
||||
if !ok || lanes["store"] != "ok" {
|
||||
t.Fatalf("lanes detail missing: %v", body["lanes"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadByIDEndpoint(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
code, body := get(t, ts.URL+"/v1/memory/read?id=1")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("read: got %d", code)
|
||||
}
|
||||
if body["status"] != FoundUnverified {
|
||||
t.Fatalf("no digest on record → FOUND_UNVERIFIED, got %v", body["status"])
|
||||
}
|
||||
if body["text"] == "" {
|
||||
t.Fatal("verbatim text missing")
|
||||
}
|
||||
// not found is typed, not a 404 envelope
|
||||
_, body = get(t, ts.URL+"/v1/memory/read?id=999")
|
||||
if body["status"] != NotFoundConfirmed {
|
||||
t.Fatalf("NOT_FOUND_CONFIRMED, got %v", body["status"])
|
||||
}
|
||||
// bad id is a 400 envelope
|
||||
code, body = get(t, ts.URL+"/v1/memory/read?id=abc")
|
||||
if code != http.StatusBadRequest || body["error"] == nil {
|
||||
t.Fatalf("bad id: got %d %v", code, body)
|
||||
}
|
||||
// missing id is a 400 envelope
|
||||
code, body = get(t, ts.URL+"/v1/memory/read")
|
||||
if code != http.StatusBadRequest || body["error"] == nil {
|
||||
t.Fatalf("missing id: got %d %v", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadByIDVerifiedWithDigest(t *testing.T) {
|
||||
db, err := OpenStore(filepath.Join(t.TempDir(), "mem.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(
|
||||
"INSERT INTO entries (text, created_at, status) VALUES ('pinned evidence', '2026-10-21T00:00:00+00:00', 'active')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := DigestOf("pinned evidence")
|
||||
ts := httptest.NewServer(NewServer(db, NewTopicContinuity(t.TempDir())).Handler())
|
||||
defer ts.Close()
|
||||
_, body := get(t, ts.URL+"/v1/memory/read?id=1&digest="+digest)
|
||||
if body["status"] != FoundVerified {
|
||||
t.Fatalf("matching digest → FOUND_VERIFIED, got %v", body["status"])
|
||||
}
|
||||
_, body = get(t, ts.URL+"/v1/memory/read?id=1&digest=sha256:deadbeef")
|
||||
if body["status"] != SourceCorrupt {
|
||||
t.Fatalf("mismatched digest → SOURCE_CORRUPT, got %v", body["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuityBlocksEndpoint(t *testing.T) {
|
||||
db, err := OpenStore(filepath.Join(t.TempDir(), "mem.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(
|
||||
"INSERT INTO entries (text, created_at, status) VALUES ('deploy checklist step one', '2026-10-21T00:00:00+00:00', 'active')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tc := NewTopicContinuity(t.TempDir())
|
||||
if err := tc.Foreground(db, "topic:deploy", []int64{1}, []string{"await signoff"}, "task-7", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(NewServer(db, tc).Handler())
|
||||
defer ts.Close()
|
||||
code, body := get(t, ts.URL+"/v1/memory/continuity")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("continuity: got %d", code)
|
||||
}
|
||||
if body["active_topic"] != "topic:deploy" || body["has_active"] != true {
|
||||
t.Fatalf("active topic: %v", body)
|
||||
}
|
||||
ws, ok := body["working_set"].([]any)
|
||||
if !ok || len(ws) != 1 {
|
||||
t.Fatalf("working set: %v", body["working_set"])
|
||||
}
|
||||
blocks, ok := body["blocks"].([]any)
|
||||
if !ok || len(blocks) != 1 {
|
||||
t.Fatalf("continuity blocks: %v", body["blocks"])
|
||||
}
|
||||
blk := blocks[0].(map[string]any)
|
||||
if blk["name"] != "memory" || blk["priority"].(float64) != MemoryBlockPriority {
|
||||
t.Fatalf("block shape: %v", blk)
|
||||
}
|
||||
states := body["topic_states"].(map[string]any)
|
||||
if states["topic:deploy"].(map[string]any)["state"] != StateHot {
|
||||
t.Fatalf("topic state: %v", states)
|
||||
}
|
||||
// read-only: a GET must not have changed the store
|
||||
var n int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM audit_log").Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("read surface wrote to the store: audit_log=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointEndpointReadOnly(t *testing.T) {
|
||||
db, err := OpenStore(filepath.Join(t.TempDir(), "mem.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
tc := NewTopicContinuity(t.TempDir())
|
||||
if _, err := db.Exec(
|
||||
"INSERT INTO entries (text, created_at, status) VALUES ('evidence A', '2026-10-21T00:00:00+00:00', 'active')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tc.Foreground(db, "topic:a", []int64{1}, nil, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tc.Cool(db, "topic:a", "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(NewServer(db, tc).Handler())
|
||||
defer ts.Close()
|
||||
// after cool, the registry has no active topic — a fresh server sees it via peek
|
||||
code, body := get(t, ts.URL+"/v1/memory/checkpoint?topic=topic:a")
|
||||
if code != http.StatusOK || body["topic_id"] != "topic:a" || body["state"] != "committed" {
|
||||
t.Fatalf("checkpoint peek: %d %v", code, body)
|
||||
}
|
||||
// unknown topic → typed receipt, never fabricated
|
||||
_, body = get(t, ts.URL+"/v1/memory/checkpoint?topic=topic:zz")
|
||||
if body["status"] != NotFoundConfirmed {
|
||||
t.Fatalf("unknown topic: %v", body)
|
||||
}
|
||||
// missing topic → 400 envelope
|
||||
code, body = get(t, ts.URL+"/v1/memory/checkpoint")
|
||||
if code != http.StatusBadRequest || body["error"] == nil {
|
||||
t.Fatalf("missing topic: %d %v", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyGate(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
|
||||
req, _ := http.NewRequest(method, ts.URL+"/v1/memory/status", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("%s on the read surface must be 405, got %d", method, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
// no route is the runecore envelope
|
||||
code, body := get(t, ts.URL+"/v1/memory/write")
|
||||
if code != http.StatusNotFound || body["error"] == nil {
|
||||
t.Fatalf("unknown route: %d %v", code, body)
|
||||
}
|
||||
if fmt.Sprint(body["error"].(map[string]any)["code"]) != "not_found" {
|
||||
t.Fatalf("envelope code: %v", body)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user