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:
Nick committed 2026-10-21 21:00:10 +01:00
1 parent 5398f9b522
commit fd8dd9bfce
4 files changed
+432 -7

No files matched your search

+33 -6
View File
@@ -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
+18 -1
View File
@@ -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)
+164
View File
@@ -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,
})
}
+217
View File
@@ -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)
}
}