feat: show Creator action history
CI / test (pull_request) Successful in 10s
CI / test (push) Successful in 10s

This commit was merged in pull request #5.
This commit is contained in:
m
2026-08-16 18:40:58 +03:00
parent 2d2615b734
commit 0e56fe7662
11 changed files with 495 additions and 11 deletions
+60
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
@@ -37,6 +38,8 @@ type creatorAuthoritySource interface {
type creatorCommitRepository interface {
executionRepository
SaveAuthorized(context.Context, commitcontract.AuthorizedAction) error
ListAuthorizedByOwner(context.Context, identity.ActorID, identity.PrincipalID, int) ([]commitcontract.AuthorizedAction, error)
FindLatestAttempt(context.Context, commitcontract.AuthorizedActionID) (commitcontract.ActionAttempt, bool, error)
}
type creatorCommandHandler struct {
@@ -64,9 +67,66 @@ type creatorActionResponse struct {
State commitcontract.ActionState
}
type creatorActionHistoryItem struct {
AuthorizedActionID commitcontract.AuthorizedActionID
ActionIntentID commitcontract.ActionIntentID
ActionAttemptID commitcontract.ActionAttemptID
Operation commitcontract.ExternalOperation
ProspectiveItemID continuity.ProspectiveItemID
State commitcontract.ActionState
AuthorizedAtUnixMillis int64
}
type creatorActionHistoryResponse struct {
Items []creatorActionHistoryItem
}
func registerCreatorCommandRoutes(mux *http.ServeMux, handler creatorCommandHandler) {
mux.HandleFunc("POST /v1/creator/continuity/satisfy-delivered", handler.satisfyDelivered)
mux.HandleFunc("POST /v1/creator/authorized-actions/{id}/reconcile", handler.reconcile)
mux.HandleFunc("GET /v1/creator/authorized-actions", handler.listAuthorizedActions)
}
func (h creatorCommandHandler) listAuthorizedActions(w http.ResponseWriter, r *http.Request) {
session, sessionOK := h.creatorSession(w, r, r.Header.Get("Authorization"))
if !sessionOK {
return
}
limit := 25
if raw := r.URL.Query().Get("limit"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 || parsed > 50 {
http.Error(w, "invalid action history limit", http.StatusBadRequest)
return
}
limit = parsed
}
actions, err := h.repository.ListAuthorizedByOwner(r.Context(), session.ActorID, session.PrincipalID, limit)
if err != nil {
http.Error(w, "Commit history unavailable", http.StatusServiceUnavailable)
return
}
items := make([]creatorActionHistoryItem, 0, len(actions))
for _, action := range actions {
attempt, found, findErr := h.repository.FindLatestAttempt(r.Context(), action.ID)
if findErr != nil {
http.Error(w, "Commit history unavailable", http.StatusServiceUnavailable)
return
}
item := creatorActionHistoryItem{
AuthorizedActionID: action.ID, ActionIntentID: action.Proposal.IntentID,
Operation: action.Proposal.Operation, ProspectiveItemID: action.Proposal.ContinuitySubject.ProspectiveItemID,
State: action.State, AuthorizedAtUnixMillis: action.AuthorizedAtUnixMillis,
}
if found {
item.ActionAttemptID = attempt.ID
item.State = attempt.State
}
items = append(items, item)
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(creatorActionHistoryResponse{Items: items})
}
func (h creatorCommandHandler) satisfyDelivered(w http.ResponseWriter, r *http.Request) {
+100
View File
@@ -53,6 +53,43 @@ func (f fakeCreatorAuthority) LoadGrant(context.Context, authority.GrantID) (aut
return f.grant, f.scope, nil
}
func (f *fakeExecutionRepository) ListAuthorizedByOwner(context.Context, identity.ActorID, identity.PrincipalID, int) ([]commitcontract.AuthorizedAction, error) {
if f.action.ID == "" {
return []commitcontract.AuthorizedAction{}, nil
}
return []commitcontract.AuthorizedAction{f.action}, nil
}
func (f *fakeExecutionRepository) FindLatestAttempt(_ context.Context, id commitcontract.AuthorizedActionID) (commitcontract.ActionAttempt, bool, error) {
for index := len(f.attempts) - 1; index >= 0; index-- {
if f.attempts[index].AuthorizedID == id {
return f.attempts[index], true, nil
}
}
return commitcontract.ActionAttempt{}, false, nil
}
type fakeCreatorHistoryRepository struct {
fakeExecutionRepository
actions []commitcontract.AuthorizedAction
latest map[commitcontract.AuthorizedActionID]commitcontract.ActionAttempt
actorID identity.ActorID
principalID identity.PrincipalID
limit int
listCalls int
}
func (f *fakeCreatorHistoryRepository) ListAuthorizedByOwner(_ context.Context, actorID identity.ActorID, principalID identity.PrincipalID, limit int) ([]commitcontract.AuthorizedAction, error) {
f.actorID, f.principalID, f.limit = actorID, principalID, limit
f.listCalls++
return f.actions, nil
}
func (f *fakeCreatorHistoryRepository) FindLatestAttempt(_ context.Context, id commitcontract.AuthorizedActionID) (commitcontract.ActionAttempt, bool, error) {
attempt, ok := f.latest[id]
return attempt, ok, nil
}
func TestCreatorCommandDerivesAuthorityAndActionFieldsServerSide(t *testing.T) {
handler, repository, executor := validCreatorCommandHandler()
mux := http.NewServeMux()
@@ -127,6 +164,69 @@ func TestCreatorCommandRejectsSessionForeignOwnerStaleStateAndRevokedGrant(t *te
}
}
func TestCreatorActionHistoryUsesSessionOwnerAndShowsMissingAttempt(t *testing.T) {
session := identity.CreatorSession{ActorID: "creator-actor", PrincipalID: "creator-principal", ExpiresAtUnix: 1000}
repository := &fakeCreatorHistoryRepository{
actions: []commitcontract.AuthorizedAction{
{ID: "authorized-new", PrincipalID: session.PrincipalID, AuthorizedAtUnixMillis: 20, State: commitcontract.ActionAuthorized, Proposal: commitcontract.ActionProposal{ActorID: session.ActorID, IntentID: "intent-new", Operation: commitcontract.OperationSatisfyDelivered, ContinuitySubject: continuity.SatisfyDeliveredSubject{ProspectiveItemID: "prospective-new"}}},
{ID: "authorized-pending", PrincipalID: session.PrincipalID, AuthorizedAtUnixMillis: 10, State: commitcontract.ActionAuthorized, Proposal: commitcontract.ActionProposal{ActorID: session.ActorID, IntentID: "intent-pending", Operation: commitcontract.OperationSatisfyDelivered, ContinuitySubject: continuity.SatisfyDeliveredSubject{ProspectiveItemID: "prospective-pending"}}},
},
latest: map[commitcontract.AuthorizedActionID]commitcontract.ActionAttempt{
"authorized-new": {ID: "attempt-new", AuthorizedID: "authorized-new", IntentID: "intent-new", State: commitcontract.ActionSucceeded},
},
}
handler := creatorCommandHandler{core: fakeCreatorCore{session: session}, repository: repository}
mux := http.NewServeMux()
registerCreatorCommandRoutes(mux, handler)
request := httptest.NewRequest(http.MethodGet, "/v1/creator/authorized-actions?limit=2", nil)
request.Header.Set("Authorization", "Bearer creator-session")
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
var result creatorActionHistoryResponse
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
t.Fatal(err)
}
if repository.actorID != session.ActorID || repository.principalID != session.PrincipalID || repository.limit != 2 || repository.listCalls != 1 {
t.Fatalf("owner query actor=%q principal=%q limit=%d calls=%d", repository.actorID, repository.principalID, repository.limit, repository.listCalls)
}
if len(result.Items) != 2 || result.Items[0].State != commitcontract.ActionSucceeded || result.Items[0].ActionAttemptID != "attempt-new" ||
result.Items[1].State != commitcontract.ActionAuthorized || result.Items[1].ActionAttemptID != "" {
t.Fatalf("history=%#v", result.Items)
}
if strings.Contains(response.Body.String(), "EvidenceRef") || strings.Contains(response.Body.String(), "GrantID") {
t.Fatalf("history leaked restricted envelope fields: %s", response.Body.String())
}
}
func TestCreatorActionHistoryRejectsSessionAndInvalidLimitBeforeQuery(t *testing.T) {
for _, test := range []struct {
name string
sessionErr error
path string
status int
}{
{name: "session", sessionErr: errCreatorSessionDenied, path: "/v1/creator/authorized-actions", status: http.StatusUnauthorized},
{name: "zero", path: "/v1/creator/authorized-actions?limit=0", status: http.StatusBadRequest},
{name: "too large", path: "/v1/creator/authorized-actions?limit=51", status: http.StatusBadRequest},
{name: "invalid", path: "/v1/creator/authorized-actions?limit=many", status: http.StatusBadRequest},
} {
t.Run(test.name, func(t *testing.T) {
repository := &fakeCreatorHistoryRepository{}
handler := creatorCommandHandler{core: fakeCreatorCore{session: identity.CreatorSession{ActorID: "actor", PrincipalID: "principal"}, sessionErr: test.sessionErr}, repository: repository}
mux := http.NewServeMux()
registerCreatorCommandRoutes(mux, handler)
response := httptest.NewRecorder()
mux.ServeHTTP(response, httptest.NewRequest(http.MethodGet, test.path, nil))
if response.Code != test.status || repository.listCalls != 0 {
t.Fatalf("status=%d calls=%d", response.Code, repository.listCalls)
}
})
}
}
func validCreatorCommandHandler() (creatorCommandHandler, *fakeExecutionRepository, *knownExecutor) {
session := identity.CreatorSession{ActorID: "creator-actor", PrincipalID: "creator-principal", ExpiresAtUnix: 1000}
target := work.TaskTarget{TaskID: "task-1", GoalID: "goal-1", ScopeID: "scope-1", Revision: 3}