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:
kami
2026-07-30 23:40:20 +04:00
parent 9e6b995538
commit dda4acfbb6
10 changed files with 926 additions and 67 deletions
+93
View File
@@ -467,14 +467,107 @@ func (s *Store) getExecutionLocked(id string) (*domain.Execution, error) {
json.Unmarshal([]byte(result), &e.Result)
json.Unmarshal([]byte(evidence), &e.ResolutionEvidence)
e.IdempotencyKey = idempKey
e.ConfirmationID = confID
e.Status = domain.ExecutionStatus(status)
e.Error = errStr
e.CorrelationID = corrID
e.CausationID = causID
e.CreatedAt = parseTime(createdAt)
e.UpdatedAt = parseTime(updatedAt)
return e, nil
}
// executionColumns is the shared SELECT list for execution reads. `rowid` is
// the insertion sequence and doubles as the pagination cursor for
// ListExecutions; single-row reads ignore it.
//
// Using rowid as the cursor is safe here because executions is an ordinary
// rowid table (its PRIMARY KEY is TEXT, so rowid is a separate hidden counter)
// and nothing in this service deletes execution rows or runs VACUUM — both of
// which could renumber rowids and invalidate outstanding cursors. If either
// ever becomes true, promote this to an explicit monotonic seq column.
const executionColumns = `rowid, id, capability_id, capability_version, target_entity_id, entity_version, arguments, requested_by, origin, COALESCE(idempotency_key,''), COALESCE(confirmation_id,''), status, result, COALESCE(error,''), resolution_evidence, COALESCE(correlation_id,''), COALESCE(causation_id,''), created_at, updated_at`
// scanExecution reads one row selected with executionColumns.
func scanExecution(sc interface{ Scan(...any) error }) (*domain.Execution, error) {
e := &domain.Execution{}
var args, reqBy, origin, idempKey, confID, status, result, errStr, evidence, corrID, causID, createdAt, updatedAt string
err := sc.Scan(&e.Seq, &e.ID, &e.CapabilityID, &e.CapabilityVersion, &e.TargetEntityID, &e.EntityVersion, &args, &reqBy, &origin, &idempKey, &confID, &status, &result, &errStr, &evidence, &corrID, &causID, &createdAt, &updatedAt)
if err != nil {
return nil, err
}
json.Unmarshal([]byte(args), &e.Arguments)
json.Unmarshal([]byte(reqBy), &e.RequestedBy)
json.Unmarshal([]byte(origin), &e.Origin)
json.Unmarshal([]byte(result), &e.Result)
json.Unmarshal([]byte(evidence), &e.ResolutionEvidence)
e.IdempotencyKey = idempKey
e.ConfirmationID = confID
e.Status = domain.ExecutionStatus(status)
e.Error = errStr
e.CorrelationID = corrID
e.CausationID = causID
e.CreatedAt = parseTime(createdAt)
e.UpdatedAt = parseTime(updatedAt)
if e.Arguments == nil {
e.Arguments = map[string]any{}
}
if e.RequestedBy == nil {
e.RequestedBy = map[string]string{}
}
if e.Origin == nil {
e.Origin = map[string]string{}
}
if e.Result == nil {
e.Result = map[string]any{}
}
return e, nil
}
// MaxExecutionPageSize bounds a single ListExecutions page, matching the
// 100-row cap the changes feed uses.
const MaxExecutionPageSize = 100
// ListExecutions returns executions ordered by ascending insertion sequence.
//
// entityID, when non-empty, restricts to that target entity. sinceSeq is an
// exclusive cursor: only rows with Seq > sinceSeq are returned, the same
// convention as EventsAfter on /api/v1/changes. limit is clamped to
// MaxExecutionPageSize.
func (s *Store) ListExecutions(entityID string, sinceSeq int64, limit int) ([]*domain.Execution, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 || limit > MaxExecutionPageSize {
limit = MaxExecutionPageSize
}
query := `SELECT ` + executionColumns + ` FROM executions WHERE rowid > ?`
args := []any{sinceSeq}
if entityID != "" {
query += ` AND target_entity_id = ?`
args = append(args, entityID)
}
query += ` ORDER BY rowid ASC LIMIT ?`
args = append(args, limit)
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []*domain.Execution{}
for rows.Next() {
e, err := scanExecution(rows)
if err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// Confirmation operations
func (s *Store) CreateConfirmation(c *domain.Confirmation) error {