feat(skills-engine): SkillLister seam + read-only /v1/skills listing surface
CI Pipeline / documentation (push) Canceled after 0s
CI Pipeline / type-check (push) Canceled after 0s
CI Pipeline / gitea-offline (push) Canceled after 0s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
CI Pipeline / unit-tests (push) Canceled after 7s
CI Pipeline / lint (push) Canceled after 8s
CI Pipeline / go-surface (push) Canceled after 6s
CI Pipeline / rust-surface (push) Canceled after 7s
CI Pipeline / canary (push) Canceled after 7s
CI Pipeline / best-practices (push) Canceled after 7s
Auto-version and Release / release (push) Canceled after 23s
deploy / deploy (push) Canceled after 21s

Minimal Go SkillLister interface beside the TUI's lister seams:
RegistryLister reads the canonical registry.json verbatim (the Python
engine's authority-outside-packages store) with no caching — the next
poll always reflects the last committed state; StaticLister serves a
labeled snapshot as the honest fallback. Endpoints: /v1/skills
({object:list,source,count,data} with tui/skills Entry shape
id/kind/state), /v1/skills/status, /v1/health. GET/HEAD only; writes
stay on src/rune/skills_engine.py until parity soak. New module
rune/skills-engine, zero deps. 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 fd8dd9bfce
commit 696d55f1eb
5 files changed
+456

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
module rune/skills-engine
go 1.25.0
+44
View File
@@ -0,0 +1,44 @@
// main — serve the skills listing surface: skills-engine serve <addr>
// <skills-dir> [--static <snapshot.json>]. Default is the live registry
// lister; --static serves a labeled snapshot (honest fallback).
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
)
func main() {
args := os.Args[1:]
if len(args) < 3 || args[0] != "serve" {
fmt.Fprintln(os.Stderr, "usage: skills-engine serve <addr> <skills-dir> [--static <snapshot.json>]")
os.Exit(2)
}
addr, skillsDir := args[1], args[2]
var lister SkillLister
source := "registry"
if len(args) >= 5 && args[3] == "--static" {
data, err := os.ReadFile(args[4])
if err != nil {
fmt.Fprintf(os.Stderr, "snapshot: %v\n", err)
os.Exit(1)
}
var entries []RosterEntry
if err := json.Unmarshal(data, &entries); err != nil {
fmt.Fprintf(os.Stderr, "snapshot parse: %v\n", err)
os.Exit(1)
}
lister = &StaticLister{Entries: entries}
source = "static"
} else {
lister = NewRegistryLister(skillsDir)
}
srv := NewSkillsServer(lister, source)
fmt.Fprintf(os.Stderr, "skills-engine (%s) reading on http://%s\n", source, addr)
if err := http.ListenAndServe(addr, srv.Handler()); err != nil {
fmt.Fprintf(os.Stderr, "serve: %v\n", err)
os.Exit(1)
}
}
+115
View File
@@ -0,0 +1,115 @@
package main
// server.go — the skills listing HTTP surface (rune-core runecore
// server.go precedent: same error envelope, [OI]-style list shapes).
// READ-ONLY: the roster is served; consent/activation writes stay on
// the Python engine until parity soak, and the method gate makes a
// write verb a 405 rather than an accident.
import (
"encoding/json"
"net/http"
)
// SkillsServer is the listing surface over one SkillLister.
type SkillsServer struct {
lister SkillLister
identity string
source string // "registry" (live) or "static" (labeled snapshot)
}
// NewSkillsServer builds the surface. source labels the lister
// honestly ("registry" | "static") so a snapshot can never pass as the
// live feed.
func NewSkillsServer(l SkillLister, source string) *SkillsServer {
return &SkillsServer{lister: l, identity: "skills-engine", source: source}
}
// Handler mounts every route.
func (s *SkillsServer) Handler() http.Handler {
mux := http.NewServeMux()
handle := 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",
"skills-engine serves reads only this wave; writes stay on the Python engine")
return
}
h(w, r)
})
}
handle("/v1/skills", s.handleSkills)
handle("/v1/skills/status", s.handleStatus)
handle("/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
}
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)
}
// handleSkills: the roster listing — the same {"object":"list",
// "data":[...]} envelope the TUI's other lister consumers read, with
// roster entries of the tui/skills Entry shape (id/kind/state) plus the
// honest source label. A lister error is a 502 envelope naming the
// cause — never a silently empty roster.
func (s *SkillsServer) handleSkills(w http.ResponseWriter, r *http.Request) {
entries, err := s.lister.List()
if err != nil {
errorEnvelope(w, http.StatusBadGateway, "lister_error", err.Error())
return
}
if entries == nil {
entries = []RosterEntry{}
}
writeJSON(w, http.StatusOK, map[string]any{
"object": "list",
"source": s.source,
"count": len(entries),
"data": entries,
})
}
// handleStatus: surface identity + source truth.
func (s *SkillsServer) handleStatus(w http.ResponseWriter, r *http.Request) {
_, err := s.lister.List()
status := "ready"
if err != nil {
status = "degraded"
}
writeJSON(w, http.StatusOK, map[string]any{
"identity": s.identity,
"status": status,
"source": s.source,
})
}
func (s *SkillsServer) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"identity": s.identity,
"status": "ok",
})
}
+126
View File
@@ -0,0 +1,126 @@
// Package main — skills-engine: the Go-side read seam over the skills
// registry (retirement-map PORT→INTEGRATE→REMOVE; the Python engine
// src/rune/skills_engine.py remains the authoritative WRITE runtime
// until parity soak — this surface is read-only).
//
// The registry lives OUTSIDE the packages (skills law 5, authority
// outside packages): <config>/skills/registry.json holds patterns and
// capabilities with their state truth; the packages themselves are
// guidance-only SKILL.md. This seam reads that registry verbatim — it
// never writes, never advances a state, never mints consent.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
)
// RosterEntry is one roster line — the same shape the TUI's skills face
// consumes (tui/skills Entry: id, kind label, state truth) and the same
// shape skills_engine.roster() serves.
type RosterEntry struct {
ID string `json:"id"`
Kind string `json:"kind"`
State string `json:"state"`
}
// SkillLister is the minimal Go seam beside the TUI's other lister
// seams (ModelLister precedent): the live source of the skills roster.
// Implementations must be safe for concurrent use and must never
// mutate the underlying registry.
type SkillLister interface {
// List returns the full roster (patterns + capabilities), oldest
// (lowest id) first. An empty roster is a valid, honest answer.
List() ([]RosterEntry, error)
}
// registryFile is the on-disk registry JSON (skills_engine.py
// registry() shape): version, seq, patterns{id:{state,...}},
// capabilities{id:{kind,state,...}}.
type registryFile struct {
Version int `json:"version"`
Seq int `json:"seq"`
Patterns map[string]map[string]any `json:"patterns"`
Capabilities map[string]map[string]any `json:"capabilities"`
Extra map[string]json.RawMessage `json:"-"`
}
// RegistryLister reads the canonical registry.json read-only. This is
// the live seam over the Python engine's data: whatever the consent /
// activation flow last committed is what the roster shows — no
// snapshotting, no caching of stale state between polls.
type RegistryLister struct {
path string
mu sync.Mutex
}
// NewRegistryLister builds a lister over <skills-dir>/registry.json.
func NewRegistryLister(skillsDir string) *RegistryLister {
return &RegistryLister{path: filepath.Join(skillsDir, "registry.json")}
}
// List reads the registry and projects the roster exactly as
// skills_engine.roster() does: patterns first (kind "pattern", their
// state), then capabilities (their declared kind and state), ids sorted.
// A missing registry is an honest EMPTY roster (the engine has noticed
// nothing yet) — never an error, never a fabricated entry.
func (l *RegistryLister) List() ([]RosterEntry, error) {
l.mu.Lock()
defer l.mu.Unlock()
data, err := os.ReadFile(l.path)
if err != nil {
if os.IsNotExist(err) {
return []RosterEntry{}, nil
}
return nil, fmt.Errorf("registry read: %w", err)
}
var reg registryFile
if err := json.Unmarshal(data, &reg); err != nil {
// A corrupt registry is reported honestly — the TUI renders
// the error; the seam never guesses roster truth.
return nil, fmt.Errorf("registry %s unreadable: %w", l.path, err)
}
out := make([]RosterEntry, 0, len(reg.Patterns)+len(reg.Capabilities))
for id, p := range reg.Patterns {
out = append(out, RosterEntry{ID: id, Kind: "pattern", State: str(p["state"])})
}
for id, c := range reg.Capabilities {
kind := str(c["kind"])
if kind == "" {
kind = "skill"
}
out = append(out, RosterEntry{ID: id, Kind: kind, State: str(c["state"])})
}
sortEntries(out)
return out, nil
}
// StaticLister serves a fixed snapshot. This is the honest fallback the
// audit's gap row allows: when the live registry is not the truth
// source (a frozen fixture, a lab seed), the seam still serves — and
// the snapshot is labeled static at the endpoint, never passed off as
// live.
type StaticLister struct {
Entries []RosterEntry
}
// List returns the snapshot verbatim.
func (s *StaticLister) List() ([]RosterEntry, error) {
return append([]RosterEntry(nil), s.Entries...), nil
}
func str(v any) string {
s, _ := v.(string)
return s
}
func sortEntries(in []RosterEntry) {
for i := 1; i < len(in); i++ {
for j := i; j > 0 && in[j].ID < in[j-1].ID; j-- {
in[j], in[j-1] = in[j-1], in[j]
}
}
}
+168
View File
@@ -0,0 +1,168 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
)
func regJSON(patterns, caps string) string {
return fmtReg(patterns, caps)
}
func fmtReg(patterns, caps string) string {
return `{"version":1,"seq":3,"patterns":` + patterns + `,"capabilities":` + caps + `}`
}
func newRegistryServer(t *testing.T, reg string) *httptest.Server {
t.Helper()
dir := t.TempDir()
if reg != "" {
if err := os.WriteFile(filepath.Join(dir, "registry.json"), []byte(reg), 0o644); err != nil {
t.Fatal(err)
}
}
ts := httptest.NewServer(NewSkillsServer(NewRegistryLister(dir), "registry").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 TestSkillsListLiveRegistry(t *testing.T) {
reg := fmtReg(
`{"pat-1":{"state":"noticed"},"pat-2":{"state":"declined"}}`,
`{"cap-1":{"kind":"skill+automation","state":"active"},"cap-2":{"kind":"automation","state":"tested"}}`)
ts := newRegistryServer(t, reg)
code, body := get(t, ts.URL+"/v1/skills")
if code != http.StatusOK || body["object"] != "list" || body["source"] != "registry" {
t.Fatalf("list envelope: %d %v", code, body)
}
if body["count"].(float64) != 4 {
t.Fatalf("count: %v", body)
}
data := body["data"].([]any)
// sorted by id: capabilities first, patterns last
first := data[0].(map[string]any)
if first["id"] != "cap-1" || first["kind"] != "skill+automation" || first["state"] != "active" {
t.Fatalf("roster entry shape: %v", first)
}
last := data[3].(map[string]any)
if last["id"] != "pat-2" || last["kind"] != "pattern" || last["state"] != "declined" {
t.Fatalf("pattern entry: %v", last)
}
}
func TestSkillsListEmptyRegistryIsHonest(t *testing.T) {
// no registry file at all: the engine noticed nothing — empty list, 200
ts := newRegistryServer(t, "")
code, body := get(t, ts.URL+"/v1/skills")
if code != http.StatusOK || body["count"].(float64) != 0 {
t.Fatalf("missing registry must be an honest empty roster: %d %v", code, body)
}
if data := body["data"].([]any); len(data) != 0 {
t.Fatalf("data must be [] not null: %v", body["data"])
}
}
func TestSkillsListCorruptRegistryIsAnError(t *testing.T) {
ts := newRegistryServer(t, "{not json")
code, body := get(t, ts.URL+"/v1/skills")
if code != http.StatusBadGateway || body["error"] == nil {
t.Fatalf("corrupt registry must surface, not silently empty: %d %v", code, body)
}
code, body = get(t, ts.URL+"/v1/skills/status")
if code != http.StatusOK || body["status"] != "degraded" {
t.Fatalf("status degrades honestly: %d %v", code, body)
}
}
func TestSkillsReadsLiveState(t *testing.T) {
// the seam must serve what the (Python) write runtime last committed —
// a registry change is visible on the next poll, no stale cache
dir := t.TempDir()
path := filepath.Join(dir, "registry.json")
if err := os.WriteFile(path, []byte(fmtReg(`{}`, `{"cap-1":{"kind":"skill","state":"drafted"}}`)), 0o644); err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(NewSkillsServer(NewRegistryLister(dir), "registry").Handler())
defer ts.Close()
_, body := get(t, ts.URL+"/v1/skills")
if body["count"].(float64) != 1 {
t.Fatalf("initial roster: %v", body)
}
if err := os.WriteFile(path, []byte(fmtReg(`{}`, `{"cap-1":{"kind":"skill","state":"active"}}`)), 0o644); err != nil {
t.Fatal(err)
}
_, body = get(t, ts.URL+"/v1/skills")
entry := body["data"].([]any)[0].(map[string]any)
if entry["state"] != "active" {
t.Fatalf("stale roster: the activation was not visible on the next poll: %v", entry)
}
}
func TestStaticListerIsLabeled(t *testing.T) {
ts := httptest.NewServer(NewSkillsServer(&StaticLister{Entries: []RosterEntry{
{ID: "pat-1", Kind: "pattern", State: "proposal"},
}}, "static").Handler())
defer ts.Close()
code, body := get(t, ts.URL+"/v1/skills")
if code != http.StatusOK || body["source"] != "static" || body["count"].(float64) != 1 {
t.Fatalf("static snapshot must carry its honest label: %d %v", code, body)
}
}
func TestSkillsReadOnlyGate(t *testing.T) {
ts := newRegistryServer(t, fmtReg(`{}`, `{}`))
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
req, _ := http.NewRequest(method, ts.URL+"/v1/skills", 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)
}
}
code, body := get(t, ts.URL+"/nope")
if code != http.StatusNotFound || body["error"] == nil {
t.Fatalf("unknown route envelope: %d %v", code, body)
}
}
func TestListerConcurrentSafe(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "registry.json")
if err := os.WriteFile(path, []byte(fmtReg(`{"pat-1":{"state":"noticed"}}`, `{}`)), 0o644); err != nil {
t.Fatal(err)
}
l := NewRegistryLister(dir)
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := l.List(); err != nil {
t.Error(err)
}
}()
}
wg.Wait()
}