diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go index 4df28bf..5492fae 100644 --- a/cmd/creator-web/server.go +++ b/cmd/creator-web/server.go @@ -145,6 +145,8 @@ func creatorRoute(method, path string) (routeTarget, string, bool) { if validID(id) { return targetContinuity, "/v1/creator/prospective-items/" + id, true } + case method == http.MethodGet && path == "/api/prospective-items": + return targetContinuity, "/v1/creator/prospective-items", true } return targetCore, "", false } diff --git a/cmd/creator-web/server_test.go b/cmd/creator-web/server_test.go index 5a0215f..179398d 100644 --- a/cmd/creator-web/server_test.go +++ b/cmd/creator-web/server_test.go @@ -78,6 +78,26 @@ func TestGatewayRoutesProspectiveItemToContinuity(t *testing.T) { } } +func TestGatewayRoutesProspectiveListToContinuity(t *testing.T) { + continuity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/creator/prospective-items" || r.URL.RawQuery != "limit=25" { + t.Fatalf("unexpected Continuity list target %s?%s", r.URL.Path, r.URL.RawQuery) + } + _, _ = io.WriteString(w, `{"Items":[]}`) + })) + defer continuity.Close() + handler, err := newHandler("http://core-runtime.invalid", continuity.URL, continuity.Client()) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "/api/prospective-items?limit=25", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Items") { + 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.DefaultClient) if err != nil { diff --git a/cmd/creator-web/static/assets/console.css b/cmd/creator-web/static/assets/console.css index 2cc7f8d..a9ebdcf 100644 --- a/cmd/creator-web/static/assets/console.css +++ b/cmd/creator-web/static/assets/console.css @@ -93,6 +93,24 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--signal-brig .signal-strip strong[data-state="error"] { color: var(--bad); } .signal-strip strong[data-state="loading"] { color: var(--warn); animation: pulse 1.1s ease-in-out infinite; } +.register-panel { margin-bottom: 18px; border: 1px solid var(--line); background: rgba(16, 23, 20, .82); } +.register-panel > header { padding: 17px 18px; display: flex; align-items: end; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); } +.register-panel header p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; } +.register-panel h2 { margin: 0; font: 400 22px/1 var(--serif); } +.register-panel header > span { color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .1em; } +.register-list { max-height: 260px; overflow: auto; scrollbar-color: var(--faint) transparent; } +.register-row { width: 100%; min-height: 58px; padding: 0 18px; display: grid; grid-template-columns: minmax(160px, 1.4fr) minmax(100px, .8fr) minmax(145px, 1fr) auto; gap: 18px; align-items: center; border: 0; border-bottom: 1px solid var(--line-soft); color: var(--muted); background: transparent; text-align: left; cursor: pointer; transition: background .18s, color .18s; } +.register-row:last-child { border-bottom: 0; } +.register-row:hover { color: var(--ink); background: var(--panel-high); } +.register-row[aria-current="true"] { color: var(--ink); background: rgba(201, 168, 95, .08); box-shadow: inset 3px 0 var(--signal); } +.register-row strong, .register-row span, .register-row time { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.register-row strong { font: 600 11px/1.3 var(--mono); } +.register-row span, .register-row time { font: 10px/1.3 var(--mono); } +.register-row em { padding: 5px 8px; border: 1px solid currentColor; border-radius: 100px; color: var(--good); font: normal 600 8px/1 var(--mono); letter-spacing: .07em; } +.register-row em.warn { color: var(--warn); } +.register-row em.bad { color: var(--bad); } +.register-empty { margin: 0; padding: 22px 18px; color: var(--faint); font-size: 11px; } + .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; } @@ -164,6 +182,11 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--signal-brig .last-update { text-align: left; } .signal-strip { grid-template-columns: 1fr; } .signal-strip div { border-right: 0; border-bottom: 1px solid var(--line); } + .register-panel > header { align-items: start; } + .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; } + .register-row em { grid-column: 2; grid-row: 1 / span 2; } .data-card { padding: 20px 17px; } .data-card header { grid-template-columns: 24px 1fr; } .status-badge { grid-column: 2; justify-self: start; } diff --git a/cmd/creator-web/static/assets/console.js b/cmd/creator-web/static/assets/console.js index d543396..9c05036 100644 --- a/cmd/creator-web/static/assets/console.js +++ b/cmd/creator-web/static/assets/console.js @@ -145,12 +145,7 @@ async function loadDashboard() { setBadge("task", "НЕ ЗАГРУЖЕНА", "neutral"); setText("task-message", "Укажите задачу для проверки относительно активного фокуса."); } - if (context["prospective-id"]) work.push(loadProspective(context["prospective-id"])); - else { - setSignal("prospective", "не выбрано", "idle"); - setBadge("prospective", "НЕ ЗАГРУЖЕНА", "neutral"); - setText("prospective-message", "Укажите идентификатор ProspectiveItem для проверки владельца и состояния."); - } + work.push(loadProspectiveRegister(context["prospective-id"])); if (context["capability-id"]) work.push(loadCapability(context["capability-id"])); else setSignal("capability", "не выбрана", "idle"); @@ -195,6 +190,75 @@ async function loadTask(taskID, query) { } } +async function loadProspectiveRegister(selectedID) { + const list = byId("prospective-list"); + list.replaceChildren(); + const loading = document.createElement("p"); + loading.className = "register-empty"; + loading.textContent = "Загружаю владельческий реестр…"; + list.append(loading); + setSignal("prospective", "загрузка", "loading"); + try { + const result = await request("/api/prospective-items?limit=25"); + const items = Array.isArray(result.Items) ? result.Items : []; + setText("prospective-count", `${items.length} ${items.length === 1 ? "ЗАПИСЬ" : "ЗАПИСЕЙ"}`); + list.replaceChildren(); + if (items.length === 0) { + const empty = document.createElement("p"); + empty.className = "register-empty"; + empty.textContent = "Для текущего Principal ожиданий нет."; + list.append(empty); + setSignal("prospective", "пусто", "idle"); + setBadge("prospective", "НЕТ ЗАПИСЕЙ", "neutral"); + return; + } + let effectiveID = selectedID; + if (!effectiveID) { + effectiveID = items[0].ID; + byId("prospective-id").value = effectiveID; + saveContext(); + } + items.forEach((item) => list.append(createProspectiveRow(item, effectiveID))); + await loadProspective(effectiveID); + } catch (error) { + list.replaceChildren(); + const failed = document.createElement("p"); + failed.className = "register-empty"; + failed.textContent = error.message; + list.append(failed); + setText("prospective-count", "ОШИБКА"); + setSignal("prospective", "ошибка", "error"); + setBadge("prospective", "НЕДОСТУПНА", "bad"); + setText("prospective-message", error.message); + } +} + +function createProspectiveRow(item, selectedID) { + const row = document.createElement("button"); + row.type = "button"; + row.className = "register-row"; + row.setAttribute("aria-current", item.ID === selectedID ? "true" : "false"); + row.setAttribute("aria-label", `Открыть ожидание ${item.ID}, состояние ${item.State}`); + const id = document.createElement("strong"); + id.textContent = item.ID; + const goal = document.createElement("span"); + goal.textContent = item.GoalID || "без цели"; + const trigger = document.createElement("time"); + trigger.textContent = item.TriggerAtUnixMillis ? new Date(item.TriggerAtUnixMillis).toLocaleString("ru-RU") : "без времени"; + const state = document.createElement("em"); + state.textContent = item.State || "UNKNOWN"; + if (["TRIGGERED", "REVALIDATING", "DELIVERED"].includes(item.State)) state.className = "warn"; + if (["SUPPRESSED", "EXPIRED"].includes(item.State)) state.className = "bad"; + row.append(id, goal, trigger, state); + row.addEventListener("click", async () => { + byId("prospective-id").value = item.ID; + saveContext(); + row.parentElement.querySelectorAll(".register-row").forEach((candidate) => candidate.setAttribute("aria-current", candidate === row ? "true" : "false")); + await loadProspective(item.ID); + }); + return row; +} + async function loadProspective(prospectiveID) { setSignal("prospective", "загрузка", "loading"); try { diff --git a/cmd/creator-web/static/index.html b/cmd/creator-web/static/index.html index 7c250cd..19e9ae7 100644 --- a/cmd/creator-web/static/index.html +++ b/cmd/creator-web/static/index.html @@ -60,7 +60,7 @@ - + @@ -90,6 +90,16 @@
ВОЗМОЖНОСТЬожидает
+
+
+

Continuity / ближайшие

Реестр ожиданий

+ 0 ЗАПИСЕЙ +
+
+

Реестр ещё не загружен.

+
+
+
01

Situation

Активный фокус

НЕ ЗАГРУЖЕН
diff --git a/dev/tasks/DEV-000029-continuity-register-ui.json b/dev/tasks/DEV-000029-continuity-register-ui.json new file mode 100644 index 0000000..323d698 --- /dev/null +++ b/dev/tasks/DEV-000029-continuity-register-ui.json @@ -0,0 +1,17 @@ +{ + "task_id": "DEV-000029", + "task_revision": 1, + "architecture_revision": "bootstrap-1", + "base_repo_revision": "2877571bb3ad8484cc3efaa0cf5b9e3a3311bc60", + "risk_tier": "C", + "intent": "Expose the bounded Creator-owned Continuity list through the narrow gateway and add a selectable upcoming-items register to the Domovoy console.", + "allowed_change_scope": {"paths": ["cmd/creator-web/**", "tests/creator_web/**", "dev/tasks/DEV-000029-continuity-register-ui.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "modules/**", "contracts/**", "adapters/**", "cmd/core-runtime/**", "cmd/continuity-runtime/**", "cmd/commit-runtime/**", "deploy/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0}, + "contracts": {"consume": ["DEV-000028 Creator Continuity list"], "may_change": []}, + "semantic_owner": "none-interface-adapter", + "assumptions": ["The first 25 trigger-ordered items are sufficient for the V1 operator register", "Explicit item selection remains browser-local non-secret context"], + "acceptance_scenarios": ["gateway exposes only GET for the Continuity list", "register renders only items returned for the authenticated Principal", "selecting a row loads the canonical item detail", "empty and error states are explicit", "desktop and mobile layouts remain usable"], + "required_tests": ["unit", "gateway allowlist", "runtime HTTP", "responsive browser", "architecture", "remote Docker smoke"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/tests/creator_web/verify-remote.ps1 b/tests/creator_web/verify-remote.ps1 index fbb6587..6afe080 100644 --- a/tests/creator_web/verify-remote.ps1 +++ b/tests/creator_web/verify-remote.ps1 @@ -57,5 +57,8 @@ Post-ContinuityJson "http://localhost:8081/v1/prospective-items" @{ } $prospective = Invoke-RestMethod -Uri "$BaseURL/api/prospective-items/$prospectiveID" -Method Get -Headers @{Authorization="Bearer $($issued.Token)"} if ($prospective.ID -ne $prospectiveID -or $prospective.State -ne "ARMED") { throw "ProspectiveItem was not returned through the gateway" } +$prospectiveList = Invoke-RestMethod -Uri "$BaseURL/api/prospective-items?limit=25" -Method Get -Headers @{Authorization="Bearer $($issued.Token)"} +if (-not $prospectiveList.Items -or $prospectiveList.Items.Count -gt 25) { throw "ProspectiveItem register response is invalid" } +if ($prospectiveList.Items | Where-Object OwnerPrincipalID -ne "domovoy-creator-principal") { throw "ProspectiveItem register leaked another Principal" } -Write-Output "DEV-000027 remote Creator Continuity console: PASS" +Write-Output "DEV-000029 Creator Continuity register: PASS"