Serve capabilities through one serializer and add GET /api/v1/executions
The two "refactor later" items from REVIEW-2026-07-30.md; they share the wire types, so they land together. A capability had four divergent wire shapes — the HTTP handler, the MCP adapter, pkg/client, and Maven's vendored copy of it. There is now a single definition in pkg/client, mapped from domain by internal/wire and used by the HTTP list/create/get paths and all four MCP surfaces. It lives in pkg/client rather than internal so external consumers need not vendor internal/domain, and so producer and consumer are literally the same type. The unified shape is a strict superset of all four predecessors; nothing was dropped. It adds enabled and requires_confirmation to the list responses (never omitempty — an absent bool reads as unknown, not false), capability_id to the MCP and client shapes, and the timing/attribute/version fields previously only on get-by-ID. target_types and the list itself now serialize as [] rather than null. Both `id` and `capability_id` are deliberately kept, carrying the same value. Maven decodes `id`; the spec and the rest of the API say `capability_id`. Bearer auth is already a breaking change for that consumer, and stacking a second silent one is the wrong trade — the redundancy stays until every consumer is confirmed on capability_id, then `id` goes in an announced removal. A test pins this and says so. GET /api/v1/executions?entity_id=&since=&limit= implements spec §4.5, which the Command Center needs. `since` reuses the changes-feed cursor convention rather than inventing a second paging idiom. That cursor is the row's implicit SQLite rowid, which is safe only while nothing deletes executions and nothing VACUUMs — both would renumber and silently invalidate outstanding cursors. If retention is ever added, this must become an explicit monotonic column first; the constraint is documented at the query site. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uea55zaiWuEByEDC4UBSdd
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/hexis/internal/domain"
|
||||
"github.com/kami/hexis/internal/storage"
|
||||
)
|
||||
|
||||
func seedCapability(t *testing.T, store *storage.Store, entityID string) *domain.Capability {
|
||||
t.Helper()
|
||||
now := time.Now().UTC()
|
||||
c := &domain.Capability{
|
||||
ID: domain.NewCapabilityID(),
|
||||
Name: "docker.restart_container",
|
||||
Description: "Restart a container",
|
||||
TargetTypes: []string{"container"},
|
||||
TargetEntityID: entityID,
|
||||
Provider: "workspace_mcp",
|
||||
Operation: "docker.restart_container",
|
||||
Risk: domain.RiskMedium,
|
||||
ReadOnly: false,
|
||||
RequiresConfirmation: domain.RequiresConfirmationForRisk(domain.RiskMedium),
|
||||
Enabled: domain.EnabledForRisk(domain.RiskMedium),
|
||||
TimeoutSeconds: domain.DefaultCapabilityTimeoutSeconds,
|
||||
Attributes: map[string]any{},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Version: 1,
|
||||
}
|
||||
if err := store.CreateCapability(c); err != nil {
|
||||
t.Fatalf("create capability: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// TestCapabilityWireShape_ListAndGetAgree pins the unification: the list
|
||||
// response and the single-capability response are now produced by the same
|
||||
// serializer, so a client cannot see one shape from one endpoint and a
|
||||
// different shape from the other. Before this, list emitted a hand-built map
|
||||
// (no enabled/requires_confirmation) while get returned the raw domain struct.
|
||||
func TestCapabilityWireShape_ListAndGetAgree(t *testing.T) {
|
||||
h, store := newTestHandler(t)
|
||||
c := seedCapability(t, store, "ent_alpha")
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/api/v1/capabilities", nil)
|
||||
listW := httptest.NewRecorder()
|
||||
h.listCapabilities(listW, listReq)
|
||||
if listW.Code != http.StatusOK {
|
||||
t.Fatalf("list: expected 200, got %d: %s", listW.Code, listW.Body.String())
|
||||
}
|
||||
var list []map[string]any
|
||||
if err := json.Unmarshal(listW.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected 1 capability, got %d", len(list))
|
||||
}
|
||||
|
||||
getW := httptest.NewRecorder()
|
||||
h.getCapability(getW, httptest.NewRequest(http.MethodGet, "/api/v1/capabilities/"+c.ID, nil), c.ID)
|
||||
if getW.Code != http.StatusOK {
|
||||
t.Fatalf("get: expected 200, got %d: %s", getW.Code, getW.Body.String())
|
||||
}
|
||||
var single map[string]any
|
||||
if err := json.Unmarshal(getW.Body.Bytes(), &single); err != nil {
|
||||
t.Fatalf("decode get: %v", err)
|
||||
}
|
||||
|
||||
for k := range single {
|
||||
if _, ok := list[0][k]; !ok {
|
||||
t.Errorf("field %q present on GET by ID but missing from the list response", k)
|
||||
}
|
||||
}
|
||||
for k := range list[0] {
|
||||
if _, ok := single[k]; !ok {
|
||||
t.Errorf("field %q present in the list response but missing from GET by ID", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestListCapabilities_ExposesGuardsAndBothIDAliases pins the two wire
|
||||
// decisions at the HTTP boundary: `enabled`/`requires_confirmation` are no
|
||||
// longer omitted (the review finding), and `id`/`capability_id` are both
|
||||
// emitted for the duration of Maven's migration.
|
||||
func TestListCapabilities_ExposesGuardsAndBothIDAliases(t *testing.T) {
|
||||
h, store := newTestHandler(t)
|
||||
c := seedCapability(t, store, "ent_alpha")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.listCapabilities(w, httptest.NewRequest(http.MethodGet, "/api/v1/capabilities", nil))
|
||||
|
||||
var list []map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
got := list[0]
|
||||
|
||||
if got["id"] != c.ID || got["capability_id"] != c.ID {
|
||||
t.Errorf("id aliases: id=%v capability_id=%v want %q", got["id"], got["capability_id"], c.ID)
|
||||
}
|
||||
if enabled, ok := got["enabled"].(bool); !ok || !enabled {
|
||||
t.Errorf("enabled: got %#v, want true", got["enabled"])
|
||||
}
|
||||
if rc, ok := got["requires_confirmation"].(bool); !ok || !rc {
|
||||
t.Errorf("requires_confirmation: got %#v, want true (risk=medium)", got["requires_confirmation"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestListCapabilities_EmptyIsArrayNotNull — clients iterate unconditionally.
|
||||
func TestListCapabilities_EmptyIsArrayNotNull(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.listCapabilities(w, httptest.NewRequest(http.MethodGet, "/api/v1/capabilities", nil))
|
||||
|
||||
if body := w.Body.String(); body != "[]\n" {
|
||||
t.Errorf("expected [], got %q", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/hexis/internal/domain"
|
||||
"github.com/kami/hexis/internal/storage"
|
||||
)
|
||||
|
||||
// seedExecution inserts a finished execution against targetEntityID.
|
||||
func seedExecution(t *testing.T, store *storage.Store, capID, targetEntityID string) *domain.Execution {
|
||||
t.Helper()
|
||||
now := time.Now().UTC()
|
||||
e := &domain.Execution{
|
||||
ID: domain.NewExecutionID(),
|
||||
CapabilityID: capID,
|
||||
TargetEntityID: targetEntityID,
|
||||
Status: domain.ExecutionSucceeded,
|
||||
Arguments: map[string]any{},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := store.CreateExecution(e); err != nil {
|
||||
t.Fatalf("create execution: %v", err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func getExecutions(t *testing.T, h *Handler, query string) []*domain.Execution {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/executions"+query, nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleExecutions(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/v1/executions%s: expected 200, got %d: %s", query, w.Code, w.Body.String())
|
||||
}
|
||||
var out []*domain.Execution
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestListExecutions_FiltersByEntity pins the entity_id filter from
|
||||
// ECOSYSTEM-SPEC.md §4.5 — the Command Center renders per-entity history and
|
||||
// must not be shown another entity's executions.
|
||||
func TestListExecutions_FiltersByEntity(t *testing.T) {
|
||||
h, store := newTestHandler(t)
|
||||
|
||||
seedExecution(t, store, "cap_a", "ent_alpha")
|
||||
seedExecution(t, store, "cap_b", "ent_beta")
|
||||
wantAlpha := seedExecution(t, store, "cap_c", "ent_alpha")
|
||||
|
||||
all := getExecutions(t, h, "")
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("unfiltered: expected 3 executions, got %d", len(all))
|
||||
}
|
||||
|
||||
alpha := getExecutions(t, h, "?entity_id=ent_alpha")
|
||||
if len(alpha) != 2 {
|
||||
t.Fatalf("entity_id=ent_alpha: expected 2 executions, got %d", len(alpha))
|
||||
}
|
||||
for _, e := range alpha {
|
||||
if e.TargetEntityID != "ent_alpha" {
|
||||
t.Errorf("entity_id filter leaked execution for %s", e.TargetEntityID)
|
||||
}
|
||||
}
|
||||
if alpha[1].ID != wantAlpha.ID {
|
||||
t.Errorf("expected ascending order ending at %s, got %s", wantAlpha.ID, alpha[1].ID)
|
||||
}
|
||||
|
||||
if none := getExecutions(t, h, "?entity_id=ent_nonexistent"); len(none) != 0 {
|
||||
t.Errorf("unknown entity: expected 0 executions, got %d", len(none))
|
||||
}
|
||||
}
|
||||
|
||||
// TestListExecutions_SinceIsAnExclusiveCursor pins the pagination convention:
|
||||
// `since` is the same exclusive integer cursor the /api/v1/changes feed uses,
|
||||
// so a client learns one paging idiom. A cursor that returned rows it had
|
||||
// already seen would make the Executions surface duplicate history.
|
||||
func TestListExecutions_SinceIsAnExclusiveCursor(t *testing.T) {
|
||||
h, store := newTestHandler(t)
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
seedExecution(t, store, "cap_a", "ent_alpha")
|
||||
}
|
||||
|
||||
all := getExecutions(t, h, "")
|
||||
if len(all) != 4 {
|
||||
t.Fatalf("expected 4 executions, got %d", len(all))
|
||||
}
|
||||
for i, e := range all {
|
||||
if e.Seq == 0 {
|
||||
t.Fatalf("execution %d has no seq cursor", i)
|
||||
}
|
||||
if i > 0 && e.Seq <= all[i-1].Seq {
|
||||
t.Fatalf("seq not strictly increasing: %d then %d", all[i-1].Seq, e.Seq)
|
||||
}
|
||||
}
|
||||
|
||||
cursor := all[1].Seq
|
||||
page := getExecutions(t, h, "?since="+strconv.FormatInt(cursor, 10))
|
||||
if len(page) != 2 {
|
||||
t.Fatalf("since=%d: expected 2 executions, got %d", cursor, len(page))
|
||||
}
|
||||
for _, e := range page {
|
||||
if e.Seq <= cursor {
|
||||
t.Errorf("execution seq %d should have been excluded by since=%d", e.Seq, cursor)
|
||||
}
|
||||
}
|
||||
|
||||
// Draining the cursor yields an empty array, not null — the Command
|
||||
// Center iterates the response unconditionally.
|
||||
last := all[len(all)-1].Seq
|
||||
if drained := getExecutions(t, h, "?since="+strconv.FormatInt(last, 10)); len(drained) != 0 {
|
||||
t.Errorf("since=%d (latest): expected 0 executions, got %d", last, len(drained))
|
||||
}
|
||||
}
|
||||
|
||||
// TestListExecutions_CombinesEntityAndSince checks the two filters compose,
|
||||
// which is the actual Command Center polling call.
|
||||
func TestListExecutions_CombinesEntityAndSince(t *testing.T) {
|
||||
h, store := newTestHandler(t)
|
||||
|
||||
seedExecution(t, store, "cap_a", "ent_alpha")
|
||||
seedExecution(t, store, "cap_b", "ent_beta")
|
||||
third := seedExecution(t, store, "cap_c", "ent_alpha")
|
||||
|
||||
all := getExecutions(t, h, "")
|
||||
cursor := all[0].Seq
|
||||
|
||||
got := getExecutions(t, h, "?entity_id=ent_alpha&since="+strconv.FormatInt(cursor, 10))
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 execution, got %d", len(got))
|
||||
}
|
||||
if got[0].ID != third.ID {
|
||||
t.Errorf("expected %s, got %s", third.ID, got[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListExecutions_LimitBoundsThePage(t *testing.T) {
|
||||
h, store := newTestHandler(t)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
seedExecution(t, store, "cap_a", "ent_alpha")
|
||||
}
|
||||
|
||||
got := getExecutions(t, h, "?limit=2")
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("limit=2: expected 2 executions, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListExecutions_RejectsBadCursor(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
|
||||
for _, q := range []string{"?since=abc", "?since=-1", "?limit=0", "?limit=nope"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/executions"+q, nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleExecutions(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s: expected 400, got %d: %s", q, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListExecutions_RejectsNonGET(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/executions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleExecutions(w, req)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListExecutions_RequiresBearerToken pins that the new endpoint sits
|
||||
// behind the same auth as the rest of /api/v1/ rather than being routed
|
||||
// around it. Execution history names every entity and capability on the box;
|
||||
// it is not public.
|
||||
func TestListExecutions_RequiresBearerToken(t *testing.T) {
|
||||
h, store := newTestHandler(t)
|
||||
seedExecution(t, store, "cap_a", "ent_alpha")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.Register(mux)
|
||||
|
||||
// No token at all.
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/executions?entity_id=ent_alpha", nil))
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated: expected 401, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Wrong token.
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/executions", nil)
|
||||
req.Header.Set("Authorization", "Bearer wrong-token")
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong token: expected 401, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Correct token.
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/executions", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+testToken)
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("authenticated: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var out []*domain.Execution
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 execution, got %d", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
// TestListExecutions_RoutedSeparatelyFromGetByID guards the ServeMux split:
|
||||
// "/api/v1/executions" (list) and "/api/v1/executions/" (by ID) are distinct
|
||||
// patterns, and adding the former must not have shadowed the latter.
|
||||
func TestListExecutions_RoutedSeparatelyFromGetByID(t *testing.T) {
|
||||
h, store := newTestHandler(t)
|
||||
exec := seedExecution(t, store, "cap_a", "ent_alpha")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.Register(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/executions/"+exec.ID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+testToken)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get by id: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var single domain.Execution
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &single); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if single.ID != exec.ID {
|
||||
t.Errorf("expected %s, got %s", exec.ID, single.ID)
|
||||
}
|
||||
}
|
||||
+51
-9
@@ -206,7 +206,7 @@ func (h *Handler) createCapability(w http.ResponseWriter, r *http.Request) {
|
||||
Payload: map[string]any{"capability_id": cap.ID, "name": cap.Name},
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusCreated, cap)
|
||||
writeJSON(w, http.StatusCreated, wire.Capability(cap))
|
||||
}
|
||||
|
||||
func (h *Handler) handleCapabilityByID(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -232,7 +232,7 @@ func (h *Handler) getCapability(w http.ResponseWriter, r *http.Request, id strin
|
||||
writeJSON(w, http.StatusNotFound, errorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cap)
|
||||
writeJSON(w, http.StatusOK, wire.Capability(cap))
|
||||
}
|
||||
|
||||
func (h *Handler) deleteCapability(w http.ResponseWriter, r *http.Request, id string) {
|
||||
@@ -327,19 +327,61 @@ func (h *Handler) handleConfirmations(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
conf, err := h.engine.CreateConfirmation(req.CapabilityID, req.TargetEntityID, req.Requester, req.Arguments)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, domain.ErrCapabilityNotFound) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, domain.ErrCapabilityNotBound) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
writeJSON(w, status, errorResponse(err.Error()))
|
||||
writeJSON(w, executeErrorStatus(err), errorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, conf)
|
||||
}
|
||||
|
||||
// handleExecutions serves GET /api/v1/executions?entity_id=&since=&limit=
|
||||
// (ECOSYSTEM-SPEC.md §4.5) — the execution history the Command Center's
|
||||
// Overview and Executions surfaces render.
|
||||
//
|
||||
// `since` follows the same cursor convention as /api/v1/changes: an integer
|
||||
// sequence, exclusive, with results ordered ascending. Callers page by passing
|
||||
// the `seq` of the last execution they saw. This deliberately reuses the
|
||||
// changes-feed style rather than introducing a timestamp cursor, so a client
|
||||
// only has to learn one paging idiom against Hexis.
|
||||
func (h *Handler) handleExecutions(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
|
||||
var since int64
|
||||
if s := q.Get("since"); s != "" {
|
||||
parsed, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil || parsed < 0 {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse("since must be a non-negative integer sequence"))
|
||||
return
|
||||
}
|
||||
since = parsed
|
||||
}
|
||||
|
||||
limit := storage.MaxExecutionPageSize
|
||||
if l := q.Get("limit"); l != "" {
|
||||
parsed, err := strconv.Atoi(l)
|
||||
if err != nil || parsed <= 0 {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse("limit must be a positive integer"))
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
|
||||
execs, err := h.store.ListExecutions(q.Get("entity_id"), since, limit)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
if execs == nil {
|
||||
execs = []*domain.Execution{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, execs)
|
||||
}
|
||||
|
||||
func (h *Handler) handleExecutionByID(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
|
||||
|
||||
Reference in New Issue
Block a user