From 2877571bb3ad8484cc3efaa0cf5b9e3a3311bc60 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 16 Aug 2026 15:54:30 +0300 Subject: [PATCH] feat: list Creator-owned continuity items --- adapters/postgres/continuity_repository.go | 21 ++++++++++++++++ cmd/continuity-runtime/list.go | 22 ++++++++++++++++ cmd/continuity-runtime/list_test.go | 17 +++++++++++++ cmd/continuity-runtime/main.go | 25 +++++++++++++++++++ .../DEV-000028-creator-continuity-list.json | 17 +++++++++++++ tests/creator_continuity/verify-remote.ps1 | 10 +++++++- 6 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 cmd/continuity-runtime/list.go create mode 100644 cmd/continuity-runtime/list_test.go create mode 100644 dev/tasks/DEV-000028-creator-continuity-list.json diff --git a/adapters/postgres/continuity_repository.go b/adapters/postgres/continuity_repository.go index 17d3d7b..5292324 100644 --- a/adapters/postgres/continuity_repository.go +++ b/adapters/postgres/continuity_repository.go @@ -6,6 +6,7 @@ import ( "fmt" "git.cin.su/m/Assistant/contracts/continuity" + "git.cin.su/m/Assistant/contracts/identity" "github.com/jackc/pgx/v5/pgxpool" ) @@ -35,3 +36,23 @@ func (r *ContinuityRepository) Load(ctx context.Context, id continuity.Prospecti } return item, nil } + +func (r *ContinuityRepository) ByOwner(ctx context.Context, owner identity.PrincipalID, limit int) ([]continuity.ProspectiveItem, error) { + rows, err := r.pool.Query(ctx, `SELECT prospective_item_id, commitment_id, owner_principal_id, scope_id, goal_id, trigger_at_unix_millis, state, revision FROM continuity.prospective_items WHERE owner_principal_id=$1 ORDER BY trigger_at_unix_millis, prospective_item_id LIMIT $2`, owner, limit) + if err != nil { + return nil, fmt.Errorf("list prospective items by owner: %w", err) + } + defer rows.Close() + items := make([]continuity.ProspectiveItem, 0) + for rows.Next() { + var item continuity.ProspectiveItem + if err := rows.Scan(&item.ID, &item.CommitmentID, &item.OwnerPrincipalID, &item.ScopeID, &item.GoalID, &item.TriggerAtUnixMillis, &item.State, &item.Revision); err != nil { + return nil, fmt.Errorf("scan prospective item: %w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list prospective items by owner: %w", err) + } + return items, nil +} diff --git a/cmd/continuity-runtime/list.go b/cmd/continuity-runtime/list.go new file mode 100644 index 0000000..5f390b6 --- /dev/null +++ b/cmd/continuity-runtime/list.go @@ -0,0 +1,22 @@ +package main + +import ( + "fmt" + "strconv" +) + +const ( + defaultListLimit = 25 + maximumListLimit = 100 +) + +func parseListLimit(raw string) (int, error) { + if raw == "" { + return defaultListLimit, nil + } + limit, err := strconv.Atoi(raw) + if err != nil || limit < 1 || limit > maximumListLimit { + return 0, fmt.Errorf("limit must be between 1 and %d", maximumListLimit) + } + return limit, nil +} diff --git a/cmd/continuity-runtime/list_test.go b/cmd/continuity-runtime/list_test.go new file mode 100644 index 0000000..833db52 --- /dev/null +++ b/cmd/continuity-runtime/list_test.go @@ -0,0 +1,17 @@ +package main + +import "testing" + +func TestParseListLimitIsBounded(t *testing.T) { + if limit, err := parseListLimit(""); err != nil || limit != defaultListLimit { + t.Fatalf("default limit = %d, err = %v", limit, err) + } + if limit, err := parseListLimit("100"); err != nil || limit != 100 { + t.Fatalf("maximum limit = %d, err = %v", limit, err) + } + for _, value := range []string{"0", "101", "invalid", "-1"} { + if _, err := parseListLimit(value); err == nil { + t.Fatalf("invalid limit %q accepted", value) + } + } +} diff --git a/cmd/continuity-runtime/main.go b/cmd/continuity-runtime/main.go index d5657db..c3877ed 100644 --- a/cmd/continuity-runtime/main.go +++ b/cmd/continuity-runtime/main.go @@ -73,6 +73,31 @@ func main() { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(item) }) + mux.HandleFunc("GET /v1/creator/prospective-items", func(w http.ResponseWriter, r *http.Request) { + session, err := identityClient.Session(r.Context(), r.Header.Get("Authorization")) + if errors.Is(err, errCreatorSessionDenied) { + http.Error(w, "creator session denied", http.StatusUnauthorized) + return + } + if err != nil { + http.Error(w, "identity service unavailable", http.StatusServiceUnavailable) + return + } + limit, err := parseListLimit(r.URL.Query().Get("limit")) + if err != nil { + http.Error(w, "invalid list limit", http.StatusBadRequest) + return + } + items, err := repository.ByOwner(r.Context(), session.PrincipalID, limit) + if err != nil { + http.Error(w, "prospective items unavailable", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(struct { + Items []continuity.ProspectiveItem + }{Items: items}) + }) log.Printf("continuity-runtime listening on %s", addr) if err := http.ListenAndServe(addr, mux); err != nil { log.Fatal(err) diff --git a/dev/tasks/DEV-000028-creator-continuity-list.json b/dev/tasks/DEV-000028-creator-continuity-list.json new file mode 100644 index 0000000..0052b85 --- /dev/null +++ b/dev/tasks/DEV-000028-creator-continuity-list.json @@ -0,0 +1,17 @@ +{ + "task_id": "DEV-000028", + "task_revision": 1, + "architecture_revision": "bootstrap-1", + "base_repo_revision": "6a5550b7b0cf5ae80ac31e042a800ca1f1796788", + "risk_tier": "C", + "intent": "List a bounded set of Continuity-owned ProspectiveItems for the authenticated Creator Principal so the console no longer requires prior knowledge of item identifiers.", + "allowed_change_scope": {"paths": ["adapters/postgres/continuity_repository.go", "cmd/continuity-runtime/**", "tests/creator_continuity/**", "dev/tasks/DEV-000028-creator-continuity-list.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "modules/**", "contracts/**", "adapters/postgres/identity_repository.go", "cmd/core-runtime/**", "cmd/commit-runtime/**", "cmd/creator-web/**", "deploy/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0}, + "contracts": {"consume": ["contracts.identity@v1", "contracts.continuity@v1"], "may_change": []}, + "semantic_owner": "continuity", + "assumptions": ["Trigger time followed by identifier is a stable deterministic V1 listing order", "A hard maximum of 100 items is sufficient for the current operator console"], + "acceptance_scenarios": ["list contains only ProspectiveItems owned by the authenticated Principal", "list size is bounded and deterministically ordered", "invalid limits are rejected", "list query does not mutate canonical Continuity state"], + "required_tests": ["unit", "repository integration", "owner-principal isolation", "runtime HTTP", "architecture", "remote Docker smoke"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY"] +} diff --git a/tests/creator_continuity/verify-remote.ps1 b/tests/creator_continuity/verify-remote.ps1 index 4c5887e..9c3b132 100644 --- a/tests/creator_continuity/verify-remote.ps1 +++ b/tests/creator_continuity/verify-remote.ps1 @@ -55,4 +55,12 @@ $owned = docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$creat if ($LASTEXITCODE -ne 0 -or ($owned | ConvertFrom-Json).ID -ne $ownedID) { throw "owned ProspectiveItem was not returned" } Expect-GetStatus "$continuityURL/v1/creator/prospective-items/$foreignID" 403 $creatorSession -Write-Output "DEV-000026 authenticated Creator Continuity API: PASS" +$listedResponse = docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$creatorSession" $continuityContainer sh -c 'wget -qO- --header="Authorization: Bearer $CREATOR_TEST_SESSION" "$0"' "$continuityURL/v1/creator/prospective-items?limit=100" +if ($LASTEXITCODE -ne 0) { throw "Creator ProspectiveItem list failed" } +$listed = $listedResponse | ConvertFrom-Json +if (-not ($listed.Items | Where-Object ID -eq $ownedID)) { throw "owned ProspectiveItem missing from list" } +if ($listed.Items | Where-Object ID -eq $foreignID) { throw "foreign ProspectiveItem leaked into list" } +if ($listed.Items.Count -gt 100) { throw "ProspectiveItem list exceeded hard limit" } +Expect-GetStatus "$continuityURL/v1/creator/prospective-items?limit=101" 400 $creatorSession + +Write-Output "DEV-000028 Creator-owned Continuity list: PASS"