From 6d1d68e406e3ae9694aa1e4b4db49399c00ccc39 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 17:08:09 +0300 Subject: [PATCH] feat: expose agent worker capability status --- cmd/agent-worker/main.go | 6 ++ cmd/agent-worker/status.go | 40 +++++++++++ cmd/agent-worker/status_test.go | 33 +++++++++ cmd/creator-web/main.go | 1 + cmd/creator-web/server.go | 67 ++++++++++++++++--- cmd/creator-web/server_test.go | 33 +++++++++ cmd/creator-web/static/assets/console.css | 5 ++ cmd/creator-web/static/assets/console.js | 15 +++++ cmd/creator-web/static/index.html | 1 + deploy/docker-compose.prod.yml | 3 +- dev/ACTIVE_TASK.json | 4 +- .../DEV-000151-agent-worker-status-ui.json | 23 +++++++ 12 files changed, 220 insertions(+), 11 deletions(-) create mode 100644 cmd/agent-worker/status.go create mode 100644 cmd/agent-worker/status_test.go create mode 100644 dev/tasks/DEV-000151-agent-worker-status-ui.json diff --git a/cmd/agent-worker/main.go b/cmd/agent-worker/main.go index 08f37ba..c0b9f4c 100644 --- a/cmd/agent-worker/main.go +++ b/cmd/agent-worker/main.go @@ -20,6 +20,7 @@ import ( func main() { workerID := flag.String("worker-id", "agent-worker-1", "durable worker identity") + statusAddr := flag.String("status-addr", ":8090", "internal status HTTP address") staleAfter := flag.Duration("stale-after", 10*time.Minute, "age after which a claimed item is recovered") leaseTTL := flag.Duration("lease-ttl", 15*time.Minute, "executor lease duration") pollInterval := flag.Duration("poll-interval", 5*time.Second, "database-owned idle wake-up interval") @@ -47,6 +48,11 @@ func main() { } else { fmt.Fprintln(os.Stderr, "agent execution bridge is not configured; dispatch claims are disabled; recovery-only mode") } + go func() { + if serveErr := http.ListenAndServe(*statusAddr, workerStatusHandler(executor != nil)); serveErr != nil { + fmt.Fprintln(os.Stderr, "agent worker status server:", serveErr) + } + }() ctx := context.Background() for { var result application.WorkerCycleResult diff --git a/cmd/agent-worker/status.go b/cmd/agent-worker/status.go new file mode 100644 index 0000000..645eaf5 --- /dev/null +++ b/cmd/agent-worker/status.go @@ -0,0 +1,40 @@ +package main + +import ( + "encoding/json" + "net/http" +) + +type workerStatusResponse struct { + Runtime string `json:"runtime"` + ExecutorConfigured bool `json:"executor_configured"` + Mode string `json:"mode"` + Ready bool `json:"ready"` + Reason string `json:"reason"` +} + +func workerStatusHandler(executorConfigured bool) http.Handler { + status := workerStatusResponse{ + Runtime: "agent-worker", + ExecutorConfigured: executorConfigured, + Mode: "recovery-only", + Ready: false, + Reason: "AGENT_EXECUTION_BRIDGE_URL is not configured", + } + if executorConfigured { + status.Mode = "execution" + status.Ready = true + status.Reason = "typed agent execution bridge configured" + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || (r.URL.Path != "/healthz" && r.URL.Path != "/readyz" && r.URL.Path != "/status") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/readyz" && !status.Ready { + w.WriteHeader(http.StatusServiceUnavailable) + } + _ = json.NewEncoder(w).Encode(status) + }) +} diff --git a/cmd/agent-worker/status_test.go b/cmd/agent-worker/status_test.go new file mode 100644 index 0000000..8374f28 --- /dev/null +++ b/cmd/agent-worker/status_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestWorkerStatusReportsRecoveryOnlyWithoutExecutor(t *testing.T) { + response := httptest.NewRecorder() + workerStatusHandler(false).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/status", nil)) + var value workerStatusResponse + if err := json.NewDecoder(response.Body).Decode(&value); err != nil { + t.Fatal(err) + } + if response.Code != http.StatusOK || value.Ready || value.Mode != "recovery-only" || value.ExecutorConfigured { + t.Fatalf("unexpected status: code=%d value=%+v", response.Code, value) + } +} + +func TestWorkerReadyzRequiresExecutor(t *testing.T) { + response := httptest.NewRecorder() + workerStatusHandler(false).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("readyz status=%d", response.Code) + } + response = httptest.NewRecorder() + workerStatusHandler(true).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if response.Code != http.StatusOK { + t.Fatalf("configured readyz status=%d", response.Code) + } +} diff --git a/cmd/creator-web/main.go b/cmd/creator-web/main.go index 3319c34..58d04ea 100644 --- a/cmd/creator-web/main.go +++ b/cmd/creator-web/main.go @@ -15,6 +15,7 @@ func main() { envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"), &http.Client{Timeout: 10 * time.Second}, envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), + envOr("AGENT_WORKER_URL", "http://agent-worker:8090"), ) if err != nil { log.Fatal(err) diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go index 6d754fa..a894762 100644 --- a/cmd/creator-web/server.go +++ b/cmd/creator-web/server.go @@ -33,13 +33,14 @@ const ( var staticFiles embed.FS type gateway struct { - core *url.URL - continuity *url.URL - commit *url.URL - cognitive *url.URL - client *http.Client - static http.Handler - build buildMetadata + core *url.URL + continuity *url.URL + commit *url.URL + cognitive *url.URL + agentWorker *url.URL + client *http.Client + static http.Handler + build buildMetadata } type buildMetadata struct { @@ -76,6 +77,14 @@ func newHandler(coreURL, continuityURL, commitURL string, client *http.Client, c if err != nil { return nil, err } + agentWorkerURL := "http://agent-worker:8090" + if len(cognitiveURLs) > 1 { + agentWorkerURL = cognitiveURLs[1] + } + agentWorker, err := parseRuntimeURL(agentWorkerURL, "Agent worker") + if err != nil { + return nil, err + } assets, err := fs.Sub(staticFiles, "static") if err != nil { return nil, fmt.Errorf("load embedded console: %w", err) @@ -96,7 +105,7 @@ func newHandler(coreURL, continuityURL, commitURL string, client *http.Client, c if len(revision) > 12 { revision = revision[:12] } - return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, client: client, static: http.FileServer(http.FS(assets)), build: buildMetadata{revision: revision, updated: updated}}), nil + return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, agentWorker: agentWorker, client: client, static: http.FileServer(http.FS(assets)), build: buildMetadata{revision: revision, updated: updated}}), nil } func parseRuntimeURL(rawURL, name string) (*url.URL, error) { @@ -130,6 +139,10 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { g.handleFleetReadiness(w, r) return } + if r.Method == http.MethodGet && r.URL.Path == "/api/agent-worker/status" { + g.handleAgentWorkerStatus(w, r) + return + } g.forwardAPI(w, r) return } @@ -290,6 +303,44 @@ func (g *gateway) handleFleetReadiness(w http.ResponseWriter, r *http.Request) { _, _ = io.Copy(w, io.LimitReader(response.Body, maxResponseBytes)) } +func (g *gateway) handleAgentWorkerStatus(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.agentWorker + target.Path = "/status" + request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil) + if err != nil { + http.Error(w, "agent worker status unavailable", http.StatusBadGateway) + return + } + response, err := g.client.Do(request) + if err != nil { + http.Error(w, "agent worker status 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 57b4c0f..05b5934 100644 --- a/cmd/creator-web/server_test.go +++ b/cmd/creator-web/server_test.go @@ -42,6 +42,39 @@ func TestCreatorRouteAllowsOnlyBoundedDelegationReads(t *testing.T) { } } +func TestAgentWorkerStatusProxyRequiresCreatorSession(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() + worker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/status" { + t.Fatalf("unexpected worker path %s", r.URL.Path) + } + _, _ = io.WriteString(w, `{"runtime":"agent-worker","executor_configured":false,"mode":"recovery-only","ready":false,"reason":"bridge missing"}`) + })) + defer worker.Close() + handler, err := newHandler(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), "http://cognitive.invalid", worker.URL) + if err != nil { + t.Fatal(err) + } + denied := httptest.NewRecorder() + handler.ServeHTTP(denied, httptest.NewRequest(http.MethodGet, "/api/agent-worker/status", nil)) + if denied.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated status=%d", denied.Code) + } + request := httptest.NewRequest(http.MethodGet, "/api/agent-worker/status", 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(), `"mode":"recovery-only"`) || 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()) + } +} + 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" { diff --git a/cmd/creator-web/static/assets/console.css b/cmd/creator-web/static/assets/console.css index 9f09ffa..86d5be5 100644 --- a/cmd/creator-web/static/assets/console.css +++ b/cmd/creator-web/static/assets/console.css @@ -281,6 +281,11 @@ html[data-reduced-motion="true"] *, html[data-reduced-motion="true"] *::before, .agent-details span { display: block; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; } .agent-details strong { display: block; margin-top: 7px; overflow: hidden; color: var(--ink); font: 600 10px/1.4 var(--mono); text-overflow: ellipsis; white-space: nowrap; } .agent-details p { grid-column: 1 / -1; margin: 0; padding-top: 12px; border-top: 1px solid var(--line-soft); color: var(--muted); font: 10px/1.5 var(--mono); } +.agent-worker-status { margin: 0 18px 16px; padding-top: 13px; display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; border-top: 1px solid var(--line-soft); } +.agent-worker-status span { color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; } +.agent-worker-status strong { color: var(--warn); font: 600 10px/1.3 var(--mono); } +.agent-worker-status strong[data-state="ready"] { color: var(--good); } +.agent-worker-status p { grid-column: 1 / -1; margin: 0; color: var(--muted); font: 10px/1.45 var(--mono); } .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); } diff --git a/cmd/creator-web/static/assets/console.js b/cmd/creator-web/static/assets/console.js index a39ac5d..6b4ff62 100644 --- a/cmd/creator-web/static/assets/console.js +++ b/cmd/creator-web/static/assets/console.js @@ -174,6 +174,7 @@ async function showConsole() { loadLearning(); loadFleetReadiness(); loadAgentDelegation(); + loadAgentWorkerStatus(); } runDiagnostics(); } @@ -448,6 +449,7 @@ async function loadDashboard() { work.push(loadLearning()); work.push(loadFleetReadiness()); work.push(loadAgentDelegation()); + work.push(loadAgentWorkerStatus()); if (context["capability-id"]) work.push(loadCapability(context["capability-id"])); else setSignal("capability", "не выбрана", "idle"); @@ -580,6 +582,19 @@ async function loadAgentDelegation() { } } +async function loadAgentWorkerStatus() { + try { + const status = await request("/api/agent-worker/status"); + setText("agent-worker-state", status.mode || "UNKNOWN"); + setText("agent-worker-message", status.reason || "Причина не указана."); + byId("agent-worker-state").dataset.state = status.ready === true ? "ready" : "degraded"; + } catch (error) { + setText("agent-worker-state", "НЕДОСТУПЕН"); + setText("agent-worker-message", safeClientError(error)); + byId("agent-worker-state").dataset.state = "degraded"; + } +} + 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 50733d7..8c5606f 100644 --- a/cmd/creator-web/static/index.html +++ b/cmd/creator-web/static/index.html @@ -204,6 +204,7 @@
Последний blocker—

—

+
Executor capabilityПРОВЕРКА НЕ ВЫПОЛНЯЛАСЬ

Статус worker виден только в авторизованной диагностике.

diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 89e9334..ffcbad7 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -105,7 +105,7 @@ services: agent-worker: build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: agent-worker, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } } restart: unless-stopped - command: ["-worker-id", "agent-worker-production"] + command: ["-worker-id", "agent-worker-production", "-status-addr", ":8090"] environment: POSTGRES_HOST: postgres POSTGRES_PORT: "5432" @@ -257,6 +257,7 @@ services: CONTINUITY_RUNTIME_URL: http://continuity-runtime:8081 COMMIT_RUNTIME_URL: http://commit-runtime:8082 COGNITIVE_RUNTIME_URL: http://cognitive-worker:8083 + AGENT_WORKER_URL: http://agent-worker:8090 ASSISTANT_BUILD_REVISION: ${ASSISTANT_SOURCE_REVISION:-unknown} ports: ["${CREATOR_WEB_PORT:-18080}:8084"] depends_on: [core-runtime, continuity-runtime, commit-runtime, cognitive-worker] diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 84759a9..fd03c0e 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000150-agent-operations-ui.json", - "task_id": "DEV-000150", + "task_file": "dev/tasks/DEV-000151-agent-worker-status-ui.json", + "task_id": "DEV-000151", "task_revision": 1 } diff --git a/dev/tasks/DEV-000151-agent-worker-status-ui.json b/dev/tasks/DEV-000151-agent-worker-status-ui.json new file mode 100644 index 0000000..9f52de9 --- /dev/null +++ b/dev/tasks/DEV-000151-agent-worker-status-ui.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000151", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "6871e36657a10e8ad7fb1e059efdc2dde394ebbd", + "risk_tier": "E", + "intent": "Expose truthful agent-worker executor capability status to the authenticated operations cabinet while preserving recovery-only behavior when no bridge is configured.", + "requirement_ids": ["WP-301", "WP-804", "INV-TOPO-001"], + "allowed_change_scope": {"modules": ["work", "interaction", "agent-worker"], "paths": ["cmd/agent-worker/**", "cmd/creator-web/**", "deploy/docker-compose.prod.yml", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000151-agent-worker-status-ui.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", ".gitea/**", "database/**", "contracts/**", "modules/**", "adapters/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["creator session", "agent-worker status HTTP response"], "may_change": []}, + "semantic_owner": "work", + "canonical_state": [], + "state_transitions": ["executor configured -> execution status; executor absent -> recovery-only status"], + "side_effects_and_privileges": ["read-only internal status endpoint and authenticated UI proxy"], + "persistence_and_migrations": [], + "failure_retry_restart": ["status remains truthful across worker restart; no dispatch claim is introduced"], + "assumptions": ["agent-worker and creator-web share the production compose network", "AGENT_EXECUTION_BRIDGE_URL remains the only execution activation input"], + "acceptance_scenarios": ["worker status reports recovery-only when bridge absent", "readyz is non-ready without executor", "unauthenticated public status proxy returns 401", "authenticated cabinet renders degraded capability reason", "compose wiring reaches internal worker status"], + "required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "git diff --check", "check-architecture", "independent", "docker compose config"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"] +} -- 2.54.0