DEV-000040: Creator action history #5

Merged
m merged 1 commits from codex/action-history-v1 into main 2026-08-16 15:41:57 +00:00
11 changed files with 495 additions and 11 deletions
+55 -9
View File
@@ -7,6 +7,7 @@ import (
"reflect"
commitcontract "git.cin.su/m/Assistant/contracts/commit"
"git.cin.su/m/Assistant/contracts/identity"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -112,24 +113,58 @@ func (r *CommitRepository) LoadLatestAttempt(ctx context.Context, id commitcontr
return attempt, err
}
func (r *CommitRepository) FindLatestAttempt(ctx context.Context, id commitcontract.AuthorizedActionID) (commitcontract.ActionAttempt, bool, error) {
attempt, err := r.LoadLatestAttempt(ctx, id)
if errors.Is(err, ErrActionAttemptNotFound) {
return commitcontract.ActionAttempt{}, false, nil
}
return attempt, err == nil, err
}
func (r *CommitRepository) ListAuthorizedByOwner(ctx context.Context, actorID identity.ActorID, principalID identity.PrincipalID, limit int) ([]commitcontract.AuthorizedAction, error) {
if actorID == "" || principalID == "" || limit < 1 || limit > 50 {
return nil, fmt.Errorf("invalid authorized action owner query")
}
rows, err := r.pool.Query(ctx, `SELECT authorized_action_id,action_intent_id,actor_id,principal_id,grant_id,grant_revision,scope_id,
task_id,goal_id,task_scope_id,task_revision,capability_id,operation,prospective_item_id,
expected_prospective_revision,prospective_from_state,prospective_to_state,evidence_ref,
proposal_state,action_state,authorized_at_unix_millis
FROM commit.authorized_action_envelopes
WHERE actor_id=$1 AND principal_id=$2
ORDER BY authorized_at_unix_millis DESC, authorized_action_id DESC LIMIT $3`, actorID, principalID, limit)
if err != nil {
return nil, fmt.Errorf("list authorized actions: %w", err)
}
defer rows.Close()
actions := make([]commitcontract.AuthorizedAction, 0)
for rows.Next() {
var action commitcontract.AuthorizedAction
if err := scanAuthorized(rows, &action); err != nil {
return nil, fmt.Errorf("scan authorized action: %w", err)
}
actions = append(actions, action)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list authorized actions: %w", err)
}
return actions, nil
}
type commitRowQuerier interface {
QueryRow(context.Context, string, ...any) pgx.Row
}
type commitRowScanner interface {
Scan(...any) error
}
func loadAuthorized(ctx context.Context, querier commitRowQuerier, id commitcontract.AuthorizedActionID) (commitcontract.AuthorizedAction, error) {
var action commitcontract.AuthorizedAction
err := querier.QueryRow(ctx, `SELECT authorized_action_id,action_intent_id,actor_id,principal_id,grant_id,grant_revision,scope_id,
err := scanAuthorized(querier.QueryRow(ctx, `SELECT authorized_action_id,action_intent_id,actor_id,principal_id,grant_id,grant_revision,scope_id,
task_id,goal_id,task_scope_id,task_revision,capability_id,operation,prospective_item_id,
expected_prospective_revision,prospective_from_state,prospective_to_state,evidence_ref,
proposal_state,action_state,authorized_at_unix_millis
FROM commit.authorized_action_envelopes WHERE authorized_action_id=$1`, id).Scan(
&action.ID, &action.Proposal.IntentID, &action.Proposal.ActorID, &action.PrincipalID,
&action.GrantID, &action.GrantRevision, &action.ScopeID, &action.Proposal.Target.TaskID,
&action.Proposal.Target.GoalID, &action.Proposal.Target.ScopeID, &action.Proposal.Target.Revision,
&action.Proposal.CapabilityID, &action.Proposal.Operation, &action.Proposal.ContinuitySubject.ProspectiveItemID,
&action.Proposal.ContinuitySubject.ExpectedRevision, &action.Proposal.ContinuitySubject.FromState,
&action.Proposal.ContinuitySubject.ToState, &action.Proposal.ContinuitySubject.EvidenceRef,
&action.Proposal.State, &action.State, &action.AuthorizedAtUnixMillis)
FROM commit.authorized_action_envelopes WHERE authorized_action_id=$1`, id), &action)
if errors.Is(err, pgx.ErrNoRows) {
return commitcontract.AuthorizedAction{}, ErrAuthorizedActionNotFound
}
@@ -138,3 +173,14 @@ func loadAuthorized(ctx context.Context, querier commitRowQuerier, id commitcont
}
return action, nil
}
func scanAuthorized(row commitRowScanner, action *commitcontract.AuthorizedAction) error {
return row.Scan(
&action.ID, &action.Proposal.IntentID, &action.Proposal.ActorID, &action.PrincipalID,
&action.GrantID, &action.GrantRevision, &action.ScopeID, &action.Proposal.Target.TaskID,
&action.Proposal.Target.GoalID, &action.Proposal.Target.ScopeID, &action.Proposal.Target.Revision,
&action.Proposal.CapabilityID, &action.Proposal.Operation, &action.Proposal.ContinuitySubject.ProspectiveItemID,
&action.Proposal.ContinuitySubject.ExpectedRevision, &action.Proposal.ContinuitySubject.FromState,
&action.Proposal.ContinuitySubject.ToState, &action.Proposal.ContinuitySubject.EvidenceRef,
&action.Proposal.State, &action.State, &action.AuthorizedAtUnixMillis)
}
+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}
+2
View File
@@ -155,6 +155,8 @@ func creatorRoute(method, path string) (routeTarget, string, bool) {
}
case method == http.MethodGet && path == "/api/prospective-items":
return targetContinuity, "/v1/creator/prospective-items", true
case method == http.MethodGet && path == "/api/actions":
return targetCommit, "/v1/creator/authorized-actions", true
case method == http.MethodPost && path == "/api/actions/satisfy-delivered":
return targetCommit, "/v1/creator/continuity/satisfy-delivered", true
case method == http.MethodPost && strings.HasPrefix(path, "/api/actions/") && strings.HasSuffix(path, "/reconcile"):
+25
View File
@@ -158,6 +158,30 @@ func TestGatewayRoutesOnlyBoundedCreatorCommandsToCommit(t *testing.T) {
}
}
func TestGatewayRoutesCreatorActionHistoryToCommit(t *testing.T) {
commitRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/creator/authorized-actions" || r.URL.RawQuery != "limit=25" || r.Method != http.MethodGet {
t.Fatalf("unexpected Commit history target %s?%s %s", r.URL.Path, r.URL.RawQuery, r.Method)
}
if r.Header.Get("Authorization") != "Bearer bounded-session" {
t.Fatal("authorization header was not forwarded")
}
_, _ = io.WriteString(w, `{"Items":[{"AuthorizedActionID":"authorized-1"}]}`)
}))
defer commitRuntime.Close()
handler, err := newHandler("http://core-runtime.invalid", "http://continuity-runtime.invalid", commitRuntime.URL, commitRuntime.Client())
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/api/actions?limit=25", nil)
request.Header.Set("Authorization", "Bearer bounded-session")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "authorized-1") {
t.Fatalf("response = %d %s", response.Code, response.Body.String())
}
}
func TestConsoleAndSecurityHeaders(t *testing.T) {
handler, err := newHandler("http://core-runtime:8080", "http://continuity-runtime:8081", "http://commit-runtime:8082", http.DefaultClient)
if err != nil {
@@ -168,6 +192,7 @@ func TestConsoleAndSecurityHeaders(t *testing.T) {
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Домовой") ||
!strings.Contains(response.Body.String(), "satisfaction-dialog") || !strings.Contains(response.Body.String(), "Подтверждаю переход") ||
!strings.Contains(response.Body.String(), "action-history") || !strings.Contains(response.Body.String(), "Журнал действий") ||
!strings.Contains(response.Body.String(), "creator-username") || !strings.Contains(response.Body.String(), "creator-password") ||
strings.Contains(response.Body.String(), "bootstrap-token") {
t.Fatalf("console response = %d", response.Code)
+35
View File
@@ -112,6 +112,33 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
.register-row em.bad { color: var(--bad); }
.register-empty { margin: 0; padding: 22px 18px; color: var(--faint); font-size: 11px; }
.activity-panel { margin-bottom: 18px; border: 1px solid var(--line); background: rgba(13, 20, 17, .9); }
.activity-panel > header { min-height: 66px; padding: 13px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); }
.activity-panel header p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; }
.activity-panel h2 { margin: 0; font: 400 22px/1 var(--serif); }
.activity-tools { display: flex; align-items: center; gap: 12px; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .1em; }
.activity-tools .icon-button { color: var(--muted); font-size: 18px; }
.activity-list { max-height: 300px; overflow: auto; scrollbar-color: var(--faint) transparent; }
.activity-list[aria-busy="true"] { opacity: .72; }
.activity-row { min-height: 68px; padding: 11px 18px; display: grid; grid-template-columns: 112px minmax(150px, .8fr) minmax(210px, 1.2fr) 145px auto; gap: 16px; align-items: center; border-bottom: 1px solid var(--line-soft); }
.activity-row:last-child { border-bottom: 0; }
.activity-row:hover { background: var(--panel-high); }
.activity-state { width: fit-content; padding: 5px 8px; border: 1px solid currentColor; border-radius: 100px; color: var(--faint); font: 600 8px/1 var(--mono); letter-spacing: .06em; }
.activity-state[data-state="SUCCEEDED"] { color: var(--good); }
.activity-state[data-state="FAILED"] { color: var(--bad); }
.activity-state[data-state="UNKNOWN_OUTCOME"], .activity-state[data-state="EXECUTING"] { color: var(--warn); }
.activity-state[data-state="AUTHORIZED"] { color: var(--signal); }
.activity-operation, .activity-identity { min-width: 0; }
.activity-operation b, .activity-operation span, .activity-identity code, .activity-identity small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.activity-operation b { color: var(--ink); font: 600 10px/1.4 var(--mono); }
.activity-operation span, .activity-identity small { margin-top: 4px; color: var(--faint); font: 9px/1.3 var(--mono); }
.activity-identity code { color: var(--muted); font: 10px/1.4 var(--mono); }
.activity-row time { color: var(--muted); font: 9px/1.4 var(--mono); white-space: nowrap; }
.activity-reconcile { min-height: 34px; padding: 0 11px; border: 1px solid var(--warn); border-radius: var(--radius); color: var(--warn); background: transparent; cursor: pointer; font: 600 9px/1 var(--mono); }
.activity-reconcile:hover:not(:disabled) { color: var(--base); background: var(--warn); }
.activity-reconcile:disabled { opacity: .55; cursor: wait; }
.activity-error { color: var(--bad); }
.data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.data-card { min-width: 0; padding: 24px; border: 1px solid var(--line); background: linear-gradient(145deg, rgba(22, 31, 27, .94), rgba(13, 20, 17, .98)); box-shadow: 0 10px 40px rgba(0,0,0,.12); animation: reveal .55s both; }
.task-card { animation-delay: .08s; }
@@ -215,6 +242,14 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
.signal-strip { grid-template-columns: 1fr; }
.signal-strip div { border-right: 0; border-bottom: 1px solid var(--line); }
.register-panel > header { align-items: start; }
.activity-panel > header { align-items: flex-start; }
.activity-tools { align-items: flex-end; flex-direction: column-reverse; }
.activity-row { padding: 14px 16px; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 12px; }
.activity-state { grid-column: 2; grid-row: 1; }
.activity-operation { grid-column: 1; grid-row: 1; }
.activity-identity { grid-column: 1 / -1; grid-row: 2; }
.activity-row time { grid-column: 1; grid-row: 3; }
.activity-reconcile { grid-column: 2; grid-row: 2 / span 2; }
.register-row { padding: 13px 16px; grid-template-columns: minmax(0, 1fr) auto; gap: 7px 12px; }
.register-row time { grid-column: 1; grid-row: 2; }
.register-row span { display: none; }
+95
View File
@@ -80,6 +80,7 @@ async function showConsole() {
restoreContext();
const context = saveContext();
if (context["session-id"] && context["workspace-id"]) loadDashboard();
else loadActionHistory();
}
function endSession(message = "Сессия завершена.") {
@@ -136,6 +137,7 @@ contextForm.addEventListener("submit", (event) => {
});
byId("refresh-all").addEventListener("click", loadDashboard);
byId("refresh-actions").addEventListener("click", loadActionHistory);
byId("logout").addEventListener("click", () => endSession());
async function loadDashboard() {
@@ -162,6 +164,7 @@ async function loadDashboard() {
updateActionReadiness();
}
work.push(loadProspectiveRegister(context["prospective-id"]));
work.push(loadActionHistory());
if (context["capability-id"]) work.push(loadCapability(context["capability-id"]));
else setSignal("capability", "не выбрана", "idle");
@@ -169,6 +172,96 @@ async function loadDashboard() {
setText("last-update", `Обновлено ${new Intl.DateTimeFormat("ru-RU", {hour: "2-digit", minute: "2-digit", second: "2-digit"}).format(new Date())}`);
}
async function loadActionHistory() {
const list = byId("action-history");
list.setAttribute("aria-busy", "true");
list.replaceChildren();
const loading = document.createElement("p");
loading.className = "register-empty";
loading.textContent = "Читаю канонический журнал Commit…";
list.append(loading);
try {
const result = await request("/api/actions?limit=25");
const items = Array.isArray(result.Items) ? result.Items : [];
setText("action-count", `${items.length} ${items.length === 1 ? "ЗАПИСЬ" : "ЗАПИСЕЙ"}`);
list.replaceChildren();
if (items.length === 0) {
const empty = document.createElement("p");
empty.className = "register-empty";
empty.textContent = "У текущего Создателя пока нет авторизованных действий.";
list.append(empty);
return;
}
items.forEach((item) => list.append(createActionHistoryRow(item)));
} catch (error) {
list.replaceChildren();
const failed = document.createElement("p");
failed.className = "register-empty activity-error";
failed.textContent = error.message;
list.append(failed);
setText("action-count", "ОШИБКА");
} finally {
list.setAttribute("aria-busy", "false");
}
}
function createActionHistoryRow(item) {
const row = document.createElement("article");
row.className = "activity-row";
const state = document.createElement("strong");
state.className = "activity-state";
state.dataset.state = item.State || "UNKNOWN";
state.textContent = item.State || "UNKNOWN";
const operation = document.createElement("div");
operation.className = "activity-operation";
const operationName = document.createElement("b");
operationName.textContent = item.Operation || "неизвестная операция";
const subject = document.createElement("span");
subject.textContent = item.ProspectiveItemID || "без subject";
operation.append(operationName, subject);
const identity = document.createElement("div");
identity.className = "activity-identity";
const actionID = document.createElement("code");
actionID.textContent = item.AuthorizedActionID || "—";
actionID.title = item.AuthorizedActionID || "";
const attemptID = document.createElement("small");
attemptID.textContent = item.ActionAttemptID ? `attempt ${item.ActionAttemptID}` : "попытка ещё не создана";
attemptID.title = item.ActionAttemptID || "";
identity.append(actionID, attemptID);
const time = document.createElement("time");
time.dateTime = item.AuthorizedAtUnixMillis ? new Date(item.AuthorizedAtUnixMillis).toISOString() : "";
time.textContent = item.AuthorizedAtUnixMillis ? new Date(item.AuthorizedAtUnixMillis).toLocaleString("ru-RU") : "время неизвестно";
row.append(state, operation, identity, time);
if (item.State === "UNKNOWN_OUTCOME" && item.AuthorizedActionID) {
const reconcile = document.createElement("button");
reconcile.type = "button";
reconcile.className = "activity-reconcile";
reconcile.textContent = "Сверить";
reconcile.setAttribute("aria-label", `Сверить исход действия ${item.AuthorizedActionID}`);
reconcile.addEventListener("click", () => reconcileHistoryAction(item.AuthorizedActionID, reconcile));
row.append(reconcile);
}
return row;
}
async function reconcileHistoryAction(actionID, button) {
button.disabled = true;
button.textContent = "Сверяю…";
try {
const result = await request(`/api/actions/${encodeURIComponent(actionID)}/reconcile`, {
method: "POST", headers: {"Content-Type": "application/json"}, body: "{}",
});
renderActionResult(result);
notify(result.State === "SUCCEEDED" ? "Исход подтверждён." : "Исход пока неизвестен.");
await loadActionHistory();
if (currentProspective) await loadProspectiveRegister(currentProspective.ID);
} catch (error) {
notify(error.message);
button.disabled = false;
button.textContent = "Сверить";
}
}
async function loadFocus(query) {
setSignal("focus", "загрузка", "loading");
try {
@@ -398,6 +491,7 @@ byId("confirm-satisfaction").addEventListener("click", async () => {
pendingCommand = null;
byId("satisfaction-dialog").close();
renderActionResult(result);
await loadActionHistory();
notify(result.State === "SUCCEEDED" ? "Исполнение зафиксировано через Commit." : "Исход требует проверки.");
await loadProspectiveRegister(command.ProspectiveItemID);
} catch (error) {
@@ -431,6 +525,7 @@ byId("reconcile-action").addEventListener("click", async () => {
body: "{}",
});
renderActionResult(result);
await loadActionHistory();
notify(result.State === "SUCCEEDED" ? "Исход подтверждён." : "Исход пока неизвестен.");
if (currentProspective) await loadProspectiveRegister(currentProspective.ID);
} catch (error) {
+13
View File
@@ -102,6 +102,19 @@
</div>
</section>
<section class="activity-panel" aria-labelledby="activity-title">
<header>
<div><p>Commit / владелец</p><h2 id="activity-title">Журнал действий</h2></div>
<div class="activity-tools">
<span id="action-count">0 ЗАПИСЕЙ</span>
<button id="refresh-actions" class="icon-button" type="button" aria-label="Обновить журнал действий" title="Обновить журнал действий">↻</button>
</div>
</header>
<div id="action-history" class="activity-list" aria-live="polite" aria-busy="false">
<p class="register-empty">Журнал ещё не загружен.</p>
</div>
</section>
<section class="data-grid">
<article class="data-card focus-card">
<header><span class="card-number">01</span><div><p>Situation</p><h2>Активный фокус</h2></div><span id="focus-badge" class="status-badge neutral">НЕ ЗАГРУЖЕН</span></header>
+2 -2
View File
@@ -1,5 +1,5 @@
{
"task_file": "dev/tasks/DEV-000039-production-health-notifications.json",
"task_id": "DEV-000039",
"task_file": "dev/tasks/DEV-000040-creator-action-history.json",
"task_id": "DEV-000040",
"task_revision": 1
}
@@ -0,0 +1,95 @@
{
"task_id": "DEV-000040",
"task_revision": 1,
"architecture_revision": "ADR-0004",
"base_repo_revision": "2d2615b734e0df053bced78e51f548b37ea0e60c",
"risk_tier": "E",
"intent": "Expose a bounded, owner-scoped, read-only Creator action history from Commit canonical state and render it in the Domovoy console without new mutation authority, persistence, credentials, or docker-test.",
"requirement_ids": ["INV-AUTH-001", "INV-OWN-001", "CONSTITUTION-1.3.23", "USER-DOCKER-PROD-ONLY"],
"allowed_change_scope": {
"modules": ["commit"],
"paths": [
"adapters/postgres/commit_repository.go",
"cmd/commit-runtime/creator_command.go",
"cmd/commit-runtime/creator_command_test.go",
"cmd/creator-web/server.go",
"cmd/creator-web/server_test.go",
"cmd/creator-web/static/index.html",
"cmd/creator-web/static/assets/console.js",
"cmd/creator-web/static/assets/console.css",
"tests/creator_commit/verify-remote.ps1",
"dev/ACTIVE_TASK.json",
"dev/tasks/DEV-000040-creator-action-history.json"
]
},
"forbidden_change_scope": {
"paths": [
"architecture/**",
"ci/**",
".gitea/**",
"contracts/**",
"modules/identity/**",
"modules/authority/**",
"modules/work/**",
"modules/continuity/**",
"database/**",
"security/**",
"deploy/**"
]
},
"allowed_architecture_delta": {
"new_cross_module_dependencies": 0,
"new_canonical_owners": 0,
"new_privileges": 0,
"new_third_party_dependencies": 0
},
"contracts": {
"consume": ["Creator session", "contracts.commit@v1 canonical action envelope and attempt", "existing Creator gateway"],
"may_change": []
},
"semantic_owner": "commit for canonical action data; Creator UI is a read-only projection",
"canonical_state": ["existing Commit authorized action envelopes and action attempts; no new canonical state"],
"state_transitions": ["none; public query is read-only"],
"side_effects_and_privileges": ["authenticated HTTP reads from the existing commit schema", "browser renders a replaceable projection"],
"persistence_and_migrations": ["none; bounded owner-local SELECT only"],
"failure_retry_restart": ["invalid or expired session is denied before repository access", "invalid limits are rejected", "an authorized action with no attempt remains visible as AUTHORIZED", "query failure returns explicit service unavailability and does not mutate state", "UI refresh is explicit and safe to repeat"],
"assumptions": [
"The first page of at most 50 newest actions is sufficient for the V1 operational console",
"AuthorizedAtUnixMillis plus AuthorizedActionID provides deterministic descending order",
"EvidenceRef and grant details are intentionally omitted from the list response",
"docker-test remains prohibited"
],
"acceptance_scenarios": [
"missing or invalid Creator session is denied before Commit history query",
"Actor and Principal filters are taken only from the validated server session",
"history is bounded to 1 through 50 and ordered deterministically newest first",
"only Commit-owned tables are queried",
"an action without an attempt is returned as AUTHORIZED with no attempt identity",
"the gateway allowlists only the exact GET action-history route",
"the console renders loading, empty, error, succeeded, failed, unknown, executing, and authorized states without exposing evidence or credentials",
"desktop and mobile layouts remain usable and keyboard accessible",
"production smoke observes the newly committed Creator action through the authenticated history endpoint",
"exact diff validation observes only declared paths"
],
"required_tests": [
"unit",
"owner filter and bounded query",
"gateway allowlist",
"runtime HTTP",
"responsive browser",
"browser console",
"remote Docker smoke",
"PowerShell parser",
"architecture preflight",
"exact diff scope",
"go test",
"go vet",
"CI"
],
"terminal_gap_states": [
"BLOCKED_BY_ARCHITECTURE_GAP",
"BLOCKED_BY_SPEC_GAP",
"BLOCKED_BY_CONFLICT",
"BLOCKED_BY_MISSING_CAPABILITY"
]
}
+13
View File
@@ -37,6 +37,12 @@ function Creator-Post([string]$url, [object]$body, [string]$token) {
}
}
function Creator-Get([string]$url, [string]$token) {
$result = docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$token" $commitContainer sh -c 'wget -qO- --header="Authorization: Bearer $CREATOR_TEST_SESSION" "$1"' sh $url
if ($LASTEXITCODE -ne 0) { throw "Creator GET $url failed" }
return ($result -join "`n")
}
function Expect-CreatorStatus([string]$url, [object]$body, [string]$token, [int]$status) {
$json = $body | ConvertTo-Json -Depth 10 -Compress
$local = New-TemporaryFile
@@ -82,6 +88,13 @@ $action = Creator-Post http://localhost:8082/v1/creator/continuity/satisfy-deliv
if ($action.State -ne "SUCCEEDED" -or -not $action.AuthorizedActionID -or -not $action.ActionIntentID -or -not $action.ActionAttemptID) {
throw "Creator command response is incomplete"
}
$historyRaw = Creator-Get 'http://localhost:8082/v1/creator/authorized-actions?limit=50' $creatorSession
$history = $historyRaw | ConvertFrom-Json
$historyItem = @($history.Items | Where-Object { $_.AuthorizedActionID -eq $action.AuthorizedActionID })
if ($historyItem.Count -ne 1 -or $historyItem[0].ActionAttemptID -ne $action.ActionAttemptID -or $historyItem[0].State -ne 'SUCCEEDED') {
throw "Creator action history does not contain the committed outcome"
}
if ($historyRaw -match 'EvidenceRef|GrantID') { throw "Creator action history exposed restricted envelope fields" }
$updated = docker --context $DockerContext exec $continuityContainer wget -qO- "http://localhost:8081/v1/prospective-items/$prospective" | ConvertFrom-Json
if ($updated.State -ne "SATISFIED" -or $updated.Revision -ne 2) { throw "Creator command did not satisfy item exactly once" }
Expect-CreatorStatus http://localhost:8082/v1/creator/continuity/satisfy-delivered $command $creatorSession 409