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 @@
—
+Статус worker виден только в авторизованной диагностике.