This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
<input id="workspace-id" autocomplete="off" placeholder="workspace-id">
|
||||
<label for="task-id">Задача</label>
|
||||
<input id="task-id" autocomplete="off" placeholder="task-id">
|
||||
<label for="prospective-id">Ожидание</label>
|
||||
<label for="prospective-id">Выбранное ожидание</label>
|
||||
<input id="prospective-id" autocomplete="off" placeholder="prospective-item-id">
|
||||
<label for="capability-id">Возможность</label>
|
||||
<input id="capability-id" autocomplete="off" value="cognitive-proposal">
|
||||
@@ -90,6 +90,16 @@
|
||||
<div><span>ВОЗМОЖНОСТЬ</span><strong id="capability-signal" data-state="idle">ожидает</strong></div>
|
||||
</section>
|
||||
|
||||
<section class="register-panel" aria-labelledby="register-title">
|
||||
<header>
|
||||
<div><p>Continuity / ближайшие</p><h2 id="register-title">Реестр ожиданий</h2></div>
|
||||
<span id="prospective-count">0 ЗАПИСЕЙ</span>
|
||||
</header>
|
||||
<div id="prospective-list" class="register-list">
|
||||
<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>
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user