feat: expose authenticated model fleet status #120
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -181,6 +181,15 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fleet-panel" aria-labelledby="fleet-title" data-page="operations">
|
||||
<header>
|
||||
<div><p>Cognitive / capability health</p><h2 id="fleet-title">Модели и fallback</h2></div>
|
||||
<div class="activity-tools"><span id="fleet-summary">СОСТОЯНИЕ НЕ ЗАГРУЖЕНО</span><button id="refresh-fleet" class="icon-button" type="button" aria-label="Обновить состояние моделей" title="Обновить состояние моделей">↻</button></div>
|
||||
</header>
|
||||
<div class="fleet-overview"><div><span>Выбранный tier</span><strong id="fleet-selected">—</strong></div><p id="fleet-explanation">Проверка доступности провайдеров ещё не выполнялась.</p></div>
|
||||
<div id="fleet-list" class="fleet-list" aria-live="polite" aria-busy="false"><p class="register-empty">Состояние fleet ещё не загружено.</p></div>
|
||||
</section>
|
||||
|
||||
<section class="activity-panel" aria-labelledby="activity-title" data-page="operations">
|
||||
<header>
|
||||
<div><p>Commit / владелец</p><h2 id="activity-title">Журнал действий</h2></div>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user