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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user