phase 2 review: fix SSE deadlock, MCP panic, lifecycle 500, NOT NULL bug
Reviewed the phase-2 implementation (parts 2–5) end to end. The suite hung
for 600s and several handlers were never exercised because there were no
tests for the new mutation/event/MCP surface. Fixes:
- CRITICAL: sseListener ran on context.Background() and held a pooled
connection forever, so pool.Close() deadlocked (600s test timeout).
NewHandler now takes a ctx that governs the listener; ListenAndServe and
the test helper cancel it before closing the pool.
- CRITICAL: MCP AddTool panicked ("missing input schema") at construction
under go-sdk v1.6.1 — so NewHandler (and every API handler) panicked.
Added object input schemas to all 8 tools via an objSchema helper.
- HIGH: PatchEntity parsed lifecycle transitions as map[string][]string but
the shape is {from:{to:{requires:[]}}}, so every state-change PATCH 500'd.
Parse the nested shape; allow same-state no-ops.
- HIGH: CreateEntity bound SQL NULL for attributes when omitted, violating
the NOT NULL column (the default only applies when omitted). Default to
'{}'.
- MED: serveSSEWriter ignored the request ctx (per-client goroutine leak on
disconnect) and set an invalid Content-Length: -1. Thread ctx through;
omit the header. writeSSE now nil-checks the flusher (io.Pipe path passed
nil → would have panicked on first event).
- MED: SSE `data:` leaked raw sqlcgen.Event (PascalCase, base64 JSONB).
Emit canonical gen.Event so SSE matches GET /events. Verified live.
- LOW: CreateEntity uses uuid.NewV7 (ADR-0005) + real actor from context in
audit; removed dead bearerAuth; fixed vet unkeyed-field warnings.
Tests (would have caught all of the above): entity create/patch with
If-Match 409/400, valid+invalid lifecycle transitions, idempotency replay,
duplicate-slug 409, abstract-type 422, event+audit side effects, MCP tool
registration. Live smoke test confirmed NOTIFY→listener→SSE delivery.
Also adds the missing Phase 2 deliverable: Gitea Actions CI (vet,
golangci-lint, govulncheck, generated-code drift guard, race tests against
TimescaleDB, docker build) and wires sqlc into `make generate`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,23 @@ const (
|
||||
graphNodeCap = 500
|
||||
)
|
||||
|
||||
// actorInfo returns the caller's (type, label) from the request context,
|
||||
// falling back to operator/unknown when unset.
|
||||
func actorInfo(ctx context.Context) (string, string) {
|
||||
if a := GetActor(ctx); a != nil {
|
||||
typ := a.Type
|
||||
if typ == "" {
|
||||
typ = "operator"
|
||||
}
|
||||
label := a.Label
|
||||
if label == "" {
|
||||
label = a.ID
|
||||
}
|
||||
return typ, label
|
||||
}
|
||||
return "operator", "unknown"
|
||||
}
|
||||
|
||||
func clampLimit(l *int) int {
|
||||
if l == nil {
|
||||
return defaultLimit
|
||||
@@ -556,7 +573,7 @@ func (s *Server) AckSignal(ctx context.Context, req gen.AckSignalRequestObject)
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.AckSignal200JSONResponse{gen.SignalUpdatedJSONResponse(sig)}, nil
|
||||
return gen.AckSignal200JSONResponse{SignalUpdatedJSONResponse: gen.SignalUpdatedJSONResponse(sig)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) ResolveSignal(ctx context.Context, req gen.ResolveSignalRequestObject) (gen.ResolveSignalResponseObject, error) {
|
||||
@@ -593,7 +610,7 @@ func (s *Server) ResolveSignal(ctx context.Context, req gen.ResolveSignalRequest
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.ResolveSignal200JSONResponse{gen.SignalUpdatedJSONResponse(sig)}, nil
|
||||
return gen.ResolveSignal200JSONResponse{SignalUpdatedJSONResponse: gen.SignalUpdatedJSONResponse(sig)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) MuteSignal(ctx context.Context, req gen.MuteSignalRequestObject) (gen.MuteSignalResponseObject, error) {
|
||||
@@ -630,7 +647,7 @@ func (s *Server) MuteSignal(ctx context.Context, req gen.MuteSignalRequestObject
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.MuteSignal200JSONResponse{gen.SignalUpdatedJSONResponse(sig)}, nil
|
||||
return gen.MuteSignal200JSONResponse{SignalUpdatedJSONResponse: gen.SignalUpdatedJSONResponse(sig)}, nil
|
||||
}
|
||||
|
||||
// ─── Observability reads ─────────────────────────────────────────────
|
||||
@@ -758,8 +775,10 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
|
||||
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
||||
}
|
||||
|
||||
// Check idempotency if a key was provided.
|
||||
actor := "operator"
|
||||
// Check idempotency if a key was provided. The idempotency scope is the
|
||||
// calling actor, so replays are per-caller.
|
||||
actorType, actorLabel := actorInfo(ctx)
|
||||
actor := actorLabel
|
||||
var bodyHash string
|
||||
if req.Params.IdempotencyKey != nil && *req.Params.IdempotencyKey != "" {
|
||||
key := *req.Params.IdempotencyKey
|
||||
@@ -796,7 +815,10 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
|
||||
}
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slug := req.Body.Slug
|
||||
if slug == "" {
|
||||
slug = req.Body.Type + ":" + req.Body.Name
|
||||
@@ -835,7 +857,9 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
|
||||
state = defaultState
|
||||
}
|
||||
|
||||
var attrsJSON []byte
|
||||
// attributes is NOT NULL; the column default only applies when omitted,
|
||||
// not when an explicit NULL is bound — so default to an empty object.
|
||||
attrsJSON := []byte("{}")
|
||||
if req.Body.Attributes != nil {
|
||||
attrsJSON, _ = json.Marshal(req.Body.Attributes)
|
||||
}
|
||||
@@ -881,7 +905,7 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
|
||||
|
||||
// Audit.
|
||||
entityID := inserted.ID
|
||||
if auditErr := observability.Audit(ctx, q, "operator", actor, "create",
|
||||
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
|
||||
&entityID, "POST", "/api/v1/entities", "",
|
||||
map[string]any{"type": req.Body.Type, "slug": slug}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
@@ -952,8 +976,10 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// Check the transition is valid.
|
||||
var transitions map[string][]string
|
||||
// Transitions are stored as {from: {to: {requires: [...]}}}
|
||||
// (see seeds/ontology.yaml). Parse the nested shape and check
|
||||
// that an edge from→to exists.
|
||||
var transitions map[string]map[string]json.RawMessage
|
||||
if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
|
||||
return nil, fmt.Errorf("parse lifecycle transitions: %w", err)
|
||||
}
|
||||
@@ -964,20 +990,16 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
|
||||
}
|
||||
toState := *req.Body.State
|
||||
|
||||
if allowed, ok := transitions[fromState]; ok {
|
||||
found := false
|
||||
for _, s := range allowed {
|
||||
if s == toState {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
// A no-op (same state) is always allowed — the caller may be
|
||||
// updating attributes and echoing the current state.
|
||||
if toState != fromState {
|
||||
tos, ok := transitions[fromState]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: no transitions from %q", domain.ErrInvalidTransition, fromState)
|
||||
}
|
||||
if !found {
|
||||
if _, ok := tos[toState]; !ok {
|
||||
return nil, fmt.Errorf("%w: %s → %s", domain.ErrInvalidTransition, fromState, toState)
|
||||
}
|
||||
} else if fromState != "" {
|
||||
// No transitions defined from current state.
|
||||
return nil, fmt.Errorf("%w: %s → %s", domain.ErrInvalidTransition, fromState, toState)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1014,7 +1036,8 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
|
||||
entity := sqlcEntityToGen(updated)
|
||||
|
||||
// Audit.
|
||||
if auditErr := observability.Audit(ctx, q, "operator", "operator", "patch",
|
||||
patchActorType, patchActor := actorInfo(ctx)
|
||||
if auditErr := observability.Audit(ctx, q, patchActorType, patchActor, "patch",
|
||||
&id, "PATCH", "/api/v1/entities/"+req.Id, "",
|
||||
map[string]any{"version": expectedVersion}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
|
||||
Reference in New Issue
Block a user