From 6f71b73ac2b5cee8b50674b10b7a2c4cf23a3774 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 16:36:11 +0300 Subject: [PATCH] feat: expose authenticated model fleet status --- cmd/creator-web/server.go | 42 +++++++++++++++++ cmd/creator-web/server_test.go | 34 ++++++++++++++ cmd/creator-web/static/assets/console.css | 22 +++++++++ cmd/creator-web/static/assets/console.js | 47 +++++++++++++++++++ cmd/creator-web/static/index.html | 9 ++++ dev/ACTIVE_TASK.json | 4 +- .../DEV-000147-model-fleet-operations-ui.json | 23 +++++++++ 7 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000147-model-fleet-operations-ui.json diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go index b3bebfb..0125905 100644 --- a/cmd/creator-web/server.go +++ b/cmd/creator-web/server.go @@ -126,6 +126,10 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { g.handleChatMessage(w, r) return } + if r.Method == http.MethodGet && r.URL.Path == "/api/fleet/readiness" { + g.handleFleetReadiness(w, r) + return + } g.forwardAPI(w, r) return } @@ -248,6 +252,44 @@ func (g *gateway) handleAdvisoryPolicy(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(policy) } +func (g *gateway) handleFleetReadiness(w http.ResponseWriter, r *http.Request) { + authorization := r.Header.Get("Authorization") + if authorization == "" { + http.Error(w, "creator session denied", http.StatusUnauthorized) + return + } + var session identity.CreatorSession + status, err := g.getTrustedJSON(r, g.core, "/v1/creator/sessions/current", "", authorization, &session) + if err != nil { + http.Error(w, "creator session unavailable", http.StatusBadGateway) + return + } + if status != http.StatusOK || session.ActorID == "" || session.PrincipalID == "" { + if status == 0 { + status = http.StatusUnauthorized + } + http.Error(w, "creator session denied", status) + return + } + target := *g.cognitive + target.Path = "/v1/fleet/readiness" + request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil) + if err != nil { + http.Error(w, "fleet readiness unavailable", http.StatusBadGateway) + return + } + response, err := g.client.Do(request) + if err != nil { + http.Error(w, "fleet readiness unavailable", http.StatusBadGateway) + return + } + defer response.Body.Close() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(response.StatusCode) + _, _ = io.Copy(w, io.LimitReader(response.Body, maxResponseBytes)) +} + func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) { var browserRequest browserAdvisoryRequest if err := decodeStrictJSON(r.Body, maxAdvisoryRequestBytes, &browserRequest); err != nil || browserRequest.Message == "" || len(browserRequest.Message) > 16*1024 || !validID(browserRequest.SessionID) || !validID(browserRequest.WorkspaceID) { diff --git a/cmd/creator-web/server_test.go b/cmd/creator-web/server_test.go index 888bcbc..5b13c76 100644 --- a/cmd/creator-web/server_test.go +++ b/cmd/creator-web/server_test.go @@ -23,6 +23,40 @@ func TestLocalChatContextUsesOnlyActiveProject(t *testing.T) { } } +func TestFleetReadinessRequiresCreatorSessionAndProxiesCognitiveState(t *testing.T) { + core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/creator/sessions/current" || r.Header.Get("Authorization") != "Bearer bounded-session" { + t.Fatalf("unexpected session request path=%s auth=%q", r.URL.Path, r.Header.Get("Authorization")) + } + _, _ = io.WriteString(w, `{"ActorID":"creator-actor","PrincipalID":"creator-principal","ExpiresAtUnix":2000000000}`) + })) + defer core.Close() + cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/fleet/readiness" { + t.Fatalf("unexpected cognitive path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"readiness":{"members":[{"provider_instance_id":"lifeboat","model_descriptor_id":"qwen","tier":"LIFEBOAT","ready":true,"reason":"AVAILABLE"}],"ready_tiers":["LIFEBOAT"],"fallback_tier":"LIFEBOAT"},"preferred_tier":"HEAVY","selected_tier":"LIFEBOAT"}`) + })) + defer cognitiveRuntime.Close() + handler, err := newHandler(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), cognitiveRuntime.URL) + if err != nil { + t.Fatal(err) + } + denied := httptest.NewRecorder() + handler.ServeHTTP(denied, httptest.NewRequest(http.MethodGet, "/api/fleet/readiness", nil)) + if denied.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated status=%d", denied.Code) + } + request := httptest.NewRequest(http.MethodGet, "/api/fleet/readiness", 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(), `"selected_tier":"LIFEBOAT"`) || response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("response=%d cache=%q body=%s", response.Code, response.Header().Get("Cache-Control"), response.Body.String()) + } +} + const legacyExternalMode = "legacy-external-test-mode" func newHandlerWithAdvisoryMode(coreURL, continuityURL, commitURL string, client *http.Client, mode string, cognitiveURLs ...string) (http.Handler, error) { diff --git a/cmd/creator-web/static/assets/console.css b/cmd/creator-web/static/assets/console.css index 668b61a..441fd01 100644 --- a/cmd/creator-web/static/assets/console.css +++ b/cmd/creator-web/static/assets/console.css @@ -253,6 +253,24 @@ html[data-reduced-motion="true"] *, html[data-reduced-motion="true"] *::before, .activity-reconcile:disabled { opacity: .55; cursor: wait; } .activity-error { color: var(--bad); } +.fleet-panel { margin-bottom: 18px; border: 1px solid rgba(201,168,95,.32); background: linear-gradient(135deg, rgba(31,27,18,.92), rgba(13,20,17,.96)); box-shadow: inset 3px 0 0 rgba(201,168,95,.45); } +.fleet-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); } +.fleet-panel header p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; } +.fleet-panel h2 { margin: 0; font: 400 22px/1 var(--serif); } +.fleet-overview { padding: 16px 18px; display: flex; align-items: end; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line-soft); } +.fleet-overview span { display: block; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; } +.fleet-overview strong { display: block; margin-top: 6px; color: var(--signal); font: 600 16px/1 var(--mono); } +.fleet-overview p { max-width: 620px; margin: 0; color: var(--muted); font: 10px/1.45 var(--mono); } +.fleet-list { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); } +.fleet-list[aria-busy="true"] { opacity: .72; } +.fleet-row { min-width: 0; padding: 14px 18px; display: grid; gap: 7px; border-right: 1px solid var(--line-soft); } +.fleet-row:last-child { border-right: 0; } +.fleet-row strong, .fleet-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.fleet-row strong { color: var(--ink); font: 600 11px/1.3 var(--mono); } +.fleet-row span { color: var(--muted); font: 9px/1.4 var(--mono); } +.fleet-row .learning-state[data-state="READY"] { color: var(--good); } +.fleet-row .learning-state[data-state="DEGRADED"] { color: var(--warn); } + .learning-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); } .learning-grid article { min-width: 0; border-right: 1px solid var(--line-soft); } .learning-grid article:last-child { border-right: 0; } @@ -404,6 +422,10 @@ html[data-reduced-motion="true"] *, html[data-reduced-motion="true"] *::before, .learning-grid { grid-template-columns: 1fr; } .learning-grid article { border-right: 0; border-bottom: 1px solid var(--line-soft); } .learning-grid article:last-child { border-bottom: 0; } + .fleet-overview { align-items: flex-start; flex-direction: column; } + .fleet-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .fleet-row:nth-child(2n) { border-right: 0; } + .fleet-row { border-bottom: 1px solid var(--line-soft); } .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; } diff --git a/cmd/creator-web/static/assets/console.js b/cmd/creator-web/static/assets/console.js index 517b092..2367049 100644 --- a/cmd/creator-web/static/assets/console.js +++ b/cmd/creator-web/static/assets/console.js @@ -172,6 +172,7 @@ async function showConsole() { else { loadActionHistory(); loadLearning(); + loadFleetReadiness(); } runDiagnostics(); } @@ -353,6 +354,7 @@ byId("reset-settings").addEventListener("click", () => { byId("refresh-all").addEventListener("click", loadDashboard); byId("refresh-actions").addEventListener("click", loadActionHistory); byId("refresh-learning").addEventListener("click", loadLearning); +byId("refresh-fleet").addEventListener("click", loadFleetReadiness); byId("logout").addEventListener("click", () => endSession()); byId("run-diagnostics").addEventListener("click", runDiagnostics); @@ -442,6 +444,7 @@ async function loadDashboard() { work.push(loadProspectiveRegister(context["prospective-id"])); work.push(loadActionHistory()); work.push(loadLearning()); + work.push(loadFleetReadiness()); if (context["capability-id"]) work.push(loadCapability(context["capability-id"])); else setSignal("capability", "не выбрана", "idle"); @@ -500,6 +503,50 @@ function createDiagnosticItem(label, key, state, detail = "Проверяю…") return item; } +async function loadFleetReadiness() { + const list = byId("fleet-list"); + list.setAttribute("aria-busy", "true"); + list.replaceChildren(); + const loading = document.createElement("p"); + loading.className = "register-empty"; + loading.textContent = "Проверяю доступность model fleet…"; + list.append(loading); + try { + const result = await request("/api/fleet/readiness"); + const readiness = result.readiness || {}; + const members = Array.isArray(readiness.members) ? readiness.members : []; + setText("fleet-selected", result.selected_tier || "не выбран"); + setText("fleet-summary", `${members.filter((member) => member.ready).length}/${members.length} ГОТОВЫ`); + setText("fleet-explanation", result.selected_tier ? `Активный маршрут: ${result.selected_tier}. Остальные tier остаются видимыми с их фактической причиной.` : "Ни один tier не выбран: проверьте конфигурацию и health провайдеров."); + list.replaceChildren(...members.map(createFleetRow)); + } catch (error) { + setText("fleet-selected", "недоступен"); + setText("fleet-summary", "ОШИБКА"); + setText("fleet-explanation", safeClientError(error)); + const failed = document.createElement("p"); + failed.className = "register-empty activity-error"; + failed.textContent = safeClientError(error); + list.replaceChildren(failed); + } finally { + list.setAttribute("aria-busy", "false"); + } +} + +function createFleetRow(member) { + const row = document.createElement("article"); + row.className = "fleet-row"; + const state = document.createElement("em"); + state.className = "learning-state"; + state.dataset.state = member.ready ? "READY" : "DEGRADED"; + state.textContent = member.ready ? "READY" : "DEGRADED"; + const title = document.createElement("strong"); + title.textContent = member.tier || "UNKNOWN TIER"; + const detail = document.createElement("span"); + detail.textContent = `${member.model_descriptor_id || "model"} · ${member.reason || "причина не указана"}`; + row.append(state, title, detail); + return row; +} + async function loadLearning() { const experienceList = byId("experience-list"); const reflectionList = byId("reflection-list"); diff --git a/cmd/creator-web/static/index.html b/cmd/creator-web/static/index.html index cd34b21..923b77d 100644 --- a/cmd/creator-web/static/index.html +++ b/cmd/creator-web/static/index.html @@ -181,6 +181,15 @@ +
+
+

Cognitive / capability health

Модели и fallback

+
СОСТОЯНИЕ НЕ ЗАГРУЖЕНО
+
+
Выбранный tier—

Проверка доступности провайдеров ещё не выполнялась.

+

Состояние fleet ещё не загружено.

+
+

Commit / владелец

Журнал действий

diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index e8529cb..764dd52 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000146-work-graph-runtime.json", - "task_id": "DEV-000146", + "task_file": "dev/tasks/DEV-000147-model-fleet-operations-ui.json", + "task_id": "DEV-000147", "task_revision": 1 } diff --git a/dev/tasks/DEV-000147-model-fleet-operations-ui.json b/dev/tasks/DEV-000147-model-fleet-operations-ui.json new file mode 100644 index 0000000..fd2e5ea --- /dev/null +++ b/dev/tasks/DEV-000147-model-fleet-operations-ui.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000147", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "641a8cd8fbed4ccb70838f73c0cbec3e38108088", + "risk_tier": "E", + "intent": "Expose truthful model fleet and fallback readiness in the authenticated creator cabinet without widening public access.", + "requirement_ids": ["WP-601", "WP-800", "model-fleet-operations"], + "allowed_change_scope": {"modules": ["interaction", "capability"], "paths": ["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", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000147-model-fleet-operations-ui.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", ".gitea/**", "database/**", "modules/**", "contracts/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["identity.CreatorSession", "cognitive fleet readiness HTTP response"], "may_change": []}, + "semantic_owner": "interaction", + "canonical_state": [], + "state_transitions": [], + "side_effects_and_privileges": ["authenticated read-only proxy only"], + "persistence_and_migrations": [], + "failure_retry_restart": ["UI renders unavailable/degraded state; no retrying mutation"], + "assumptions": ["cognitive runtime remains the only model/provider boundary", "authenticated cabinet already has a valid creator session"], + "acceptance_scenarios": ["unauthenticated fleet request returns 401", "invalid creator session is denied", "authenticated readiness is proxied without exposing public route", "FAST/MEDIUM/HEAVY/LIFEBOAT member states render with their observed reasons", "cabinet layout remains usable at mobile width"], + "required_tests": ["go test ./cmd/creator-web", "go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"] +} -- 2.54.0