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
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:
1 parent
fd8dd9bfce
commit
696d55f1eb
5 files changed
+456
No files matched your search
@@ -0,0 +1,3 @@
|
||||
module rune/skills-engine
|
||||
|
||||
go 1.25.0
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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, ®); 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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user