diff --git a/internal/app/runtime_api.go b/internal/app/runtime_api.go index 0901c8d43..7894bc079 100644 --- a/internal/app/runtime_api.go +++ b/internal/app/runtime_api.go @@ -59,6 +59,29 @@ func (r *Runtime) RuntimeDiagnostics() Result { } } +// RuntimeOverview 是 Nexus/NexusDock Cloud 的轻量控制面投影。 +// 这里直接统计本地索引,不能调用 RuntimeSkills 等明细接口,否则一个概览请求 +// 会重新触发 Skill 包校验、digest 和文件遍历,远程链路仍会被本地扫描拖慢。 +func (r *Runtime) RuntimeOverview() (Result, error) { + tasks, err := r.taskTools.RuntimeOverview() + if err != nil { + return nil, err + } + skillCount, err := r.skills.RuntimeCount() + if err != nil { + return nil, err + } + pluginCount, pluginErr := r.plugins.RuntimeCount() + return Result{ + "ok": true, + "source": runtimeAPISource, + "tasks": tasks, + "skills": map[string]any{"count": skillCount}, + "plugins": map[string]any{"count": pluginCount, "available": pluginErr == nil}, + "mcp": map[string]any{"count": r.dynamicMCP.RuntimeCount()}, + }, nil +} + func (r *Runtime) RuntimeSkills() (Result, error) { return r.skills.RuntimeSkills() } diff --git a/internal/httpx/runtime_api.go b/internal/httpx/runtime_api.go index 2fffc85f7..b5bcc1e8d 100644 --- a/internal/httpx/runtime_api.go +++ b/internal/httpx/runtime_api.go @@ -20,6 +20,7 @@ func registerRuntimeAPI(mux *http.ServeMux, runtime runtimeapi.Runtime, cfg conf h := runtimeAPIHandler(runtime, cfg, oauthStore) mux.HandleFunc("/internal/runtime/status", h) mux.HandleFunc("/internal/runtime/analytics", h) + mux.HandleFunc("/internal/runtime/overview", h) mux.HandleFunc("/internal/runtime/diagnostics", h) mux.HandleFunc("/internal/runtime/capabilities", h) mux.HandleFunc("/internal/runtime/skills", h) diff --git a/internal/httpx/server_test.go b/internal/httpx/server_test.go index 6a2883cc3..8009c8fea 100644 --- a/internal/httpx/server_test.go +++ b/internal/httpx/server_test.go @@ -340,6 +340,7 @@ func TestRuntimeAPIMethodContract(t *testing.T) { allow string }{ {method: http.MethodGet, path: "/internal/runtime/status", status: http.StatusOK}, + {method: http.MethodGet, path: "/internal/runtime/overview", status: http.StatusOK}, {method: http.MethodGet, path: "/internal/runtime/diagnostics", status: http.StatusOK}, {method: http.MethodPost, path: "/internal/runtime/capabilities", status: http.StatusOK}, {method: http.MethodPost, path: "/internal/runtime/status", status: http.StatusMethodNotAllowed, allow: "GET"}, diff --git a/internal/runtimeapi/dispatch.go b/internal/runtimeapi/dispatch.go index e9a5b3aaa..6fe703354 100644 --- a/internal/runtimeapi/dispatch.go +++ b/internal/runtimeapi/dispatch.go @@ -56,6 +56,9 @@ func Dispatch(ctx context.Context, runtime Runtime, request Request) (map[string return map[string]any(runtime.RuntimeStatus()), nil case path == "/internal/runtime/analytics": return map[string]any(runtime.RuntimeAnalytics()), nil + case path == "/internal/runtime/overview": + result, err := runtime.RuntimeOverview() + return map[string]any(result), err case path == "/internal/runtime/diagnostics": diagnostics, ok := runtime.(DiagnosticsRuntime) if !ok { diff --git a/internal/runtimeapi/dispatch_test.go b/internal/runtimeapi/dispatch_test.go index 36dd1a9d7..81a293e01 100644 --- a/internal/runtimeapi/dispatch_test.go +++ b/internal/runtimeapi/dispatch_test.go @@ -20,8 +20,16 @@ type runtimeStub struct { pluginName string } -func (r *runtimeStub) RuntimeStatus() app.Result { return app.Result{"status": "ok"} } -func (r *runtimeStub) RuntimeAnalytics() app.Result { return app.Result{"total_calls": 7} } +func (r *runtimeStub) RuntimeStatus() app.Result { return app.Result{"status": "ok"} } +func (r *runtimeStub) RuntimeAnalytics() app.Result { return app.Result{"total_calls": 7} } +func (r *runtimeStub) RuntimeOverview() (app.Result, error) { + return app.Result{ + "tasks": map[string]int{"active": 1, "active_recent_24h": 1}, + "skills": map[string]any{"count": 2}, + "plugins": map[string]any{"count": 3, "available": true}, + "mcp": map[string]any{"count": 4}, + }, nil +} func (r *runtimeStub) RuntimeSkills() (app.Result, error) { return app.Result{}, nil } func (r *runtimeStub) RuntimeSkill(skill string) (app.Result, error) { r.skillTarget = skill @@ -111,6 +119,24 @@ func TestDispatchAnalytics(t *testing.T) { } } +func TestDispatchOverviewUsesDedicatedProjection(t *testing.T) { + result, err := Dispatch(context.Background(), &runtimeStub{}, Request{ + Method: "GET", + Path: "/internal/runtime/overview", + }) + if err != nil { + t.Fatal(err) + } + tasks, ok := result["tasks"].(map[string]int) + if !ok || tasks["active_recent_24h"] != 1 { + t.Fatalf("overview tasks = %#v", result["tasks"]) + } + skills, ok := result["skills"].(map[string]any) + if !ok || skills["count"] != 2 { + t.Fatalf("overview skills = %#v", result["skills"]) + } +} + func TestDispatchDiagnostics(t *testing.T) { result, err := Dispatch(context.Background(), &diagnosticsRuntimeStub{}, Request{ Method: "GET", diff --git a/internal/runtimeapi/runtime.go b/internal/runtimeapi/runtime.go index db9493557..701558eaf 100644 --- a/internal/runtimeapi/runtime.go +++ b/internal/runtimeapi/runtime.go @@ -14,6 +14,7 @@ import ( type Runtime interface { RuntimeStatus() app.Result RuntimeAnalytics() app.Result + RuntimeOverview() (app.Result, error) RuntimeSkills() (app.Result, error) RuntimeSkill(skill string) (app.Result, error) RuntimeSkillFiles(skill string) (app.Result, error) diff --git a/internal/tool/mcp/service.go b/internal/tool/mcp/service.go index 799e634a3..3062a8b44 100644 --- a/internal/tool/mcp/service.go +++ b/internal/tool/mcp/service.go @@ -57,6 +57,10 @@ func (s *Service) CapabilityItems() []CapabilityItem { return items } +func (s *Service) RuntimeCount() int { + return len(s.mcpClients.List()) +} + func (s *Service) Close() error { if s == nil || s.mcpClients == nil { return nil diff --git a/internal/tool/plugin/service.go b/internal/tool/plugin/service.go index 10da8d3c0..ff6f21201 100644 --- a/internal/tool/plugin/service.go +++ b/internal/tool/plugin/service.go @@ -361,6 +361,14 @@ func (s *Service) RuntimeList() (Result, error) { return Result{"plugins": plugins, "count": len(plugins)}, nil } +func (s *Service) RuntimeCount() (int, error) { + items, err := s.manager.List() + if err != nil { + return 0, pluginToolError(err) + } + return len(items), nil +} + func (s *Service) sourcePath(raw string) (string, error) { source := strings.TrimSpace(raw) if source == "" { diff --git a/internal/tool/skill/runtime_api.go b/internal/tool/skill/runtime_api.go index 2a6e7a0ad..c43281a96 100644 --- a/internal/tool/skill/runtime_api.go +++ b/internal/tool/skill/runtime_api.go @@ -20,6 +20,29 @@ type CapabilityItem struct { ContentDigest string } +// RuntimeCount 只读取已安装索引,不校验 Skill 包、计算 digest 或遍历文件。 +// 概览只需要“已安装多少个”,完整可用性与文件信息仍由 RuntimeSkills 负责。 +func (s *Service) RuntimeCount() (int, error) { + names, err := s.state.ListSkills() + if err != nil { + return 0, err + } + count := len(names) + if s.plugins == nil { + return count, nil + } + installed, err := s.plugins.List() + if err != nil { + return 0, err + } + for _, plugin := range installed { + if plugin.Enabled { + count += len(plugin.Components.Skills) + } + } + return count, nil +} + func (s *Service) CapabilityItems() ([]CapabilityItem, error) { names, err := s.state.ListSkills() if err != nil { diff --git a/internal/tool/task/runtime_api.go b/internal/tool/task/runtime_api.go index bd793491c..5820c27df 100644 --- a/internal/tool/task/runtime_api.go +++ b/internal/tool/task/runtime_api.go @@ -2,10 +2,36 @@ package task import ( "strings" + "time" "github.com/uvwt/agentdock/internal/taskstate" ) +const runtimeOverviewTaskLimit = 200 + +// RuntimeOverview 返回控制面板需要的 Task 轻量统计,避免为了几个数字把完整 +// Task 列表跨 Nexus Bridge 传输。统计上限与既有 Runtime 概览保持一致。 +func (s *Service) RuntimeOverview() (map[string]int, error) { + tasks, err := s.tasks.List("", runtimeOverviewTaskLimit) + if err != nil { + return nil, taskToolError(err) + } + counts := map[string]int{ + "active": 0, + "completed": 0, + "blocked": 0, + "active_recent_24h": 0, + } + recentCutoff := time.Now().UTC().Add(-24 * time.Hour) + for _, task := range tasks { + counts[string(task.Status)]++ + if task.Status == taskstate.StatusActive && !task.UpdatedAt.Before(recentCutoff) { + counts["active_recent_24h"]++ + } + } + return counts, nil +} + func (s *Service) RuntimeTasks(status string, limit int) (Result, error) { statusFilter := taskstate.Status(strings.ToLower(strings.TrimSpace(status))) if statusFilter != "" && statusFilter != taskstate.StatusActive && statusFilter != taskstate.StatusBlocked && statusFilter != taskstate.StatusCompleted { diff --git a/internal/tool/task/runtime_api_test.go b/internal/tool/task/runtime_api_test.go new file mode 100644 index 000000000..f74195c2b --- /dev/null +++ b/internal/tool/task/runtime_api_test.go @@ -0,0 +1,48 @@ +package task + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestRuntimeOverviewCountsTaskStatesAndRecentActiveTasks(t *testing.T) { + service, root := newTaskTestService(t) + + _, err := service.tasks.Create("recent", "recent active", []string{"done"}, nil, nil) + if err != nil { + t.Fatal(err) + } + old, err := service.tasks.Create("old", "old active", []string{"done"}, nil, nil) + if err != nil { + t.Fatal(err) + } + blocked, err := service.tasks.Create("blocked", "blocked task", []string{"done"}, nil, nil) + if err != nil { + t.Fatal(err) + } + if _, err := service.tasks.Block(blocked.ID, "waiting"); err != nil { + t.Fatal(err) + } + + // 用真实 Task JSON 只调整隔离测试目录里的更新时间,验证 24h 边界不会把旧任务算进 recent。 + old.UpdatedAt = time.Now().UTC().Add(-25 * time.Hour) + data, err := json.MarshalIndent(old, "", " ") + if err != nil { + t.Fatal(err) + } + data = append(data, '\n') + if err := os.WriteFile(filepath.Join(root, ".agentdock", "tasks", old.ID+".json"), data, 0o600); err != nil { + t.Fatal(err) + } + + counts, err := service.RuntimeOverview() + if err != nil { + t.Fatal(err) + } + if counts["active"] != 2 || counts["blocked"] != 1 || counts["completed"] != 0 || counts["active_recent_24h"] != 1 { + t.Fatalf("overview counts = %#v", counts) + } +}