diff --git a/workspace-server/cmd/server/main.go b/workspace-server/cmd/server/main.go index e11f5a96..3961a842 100644 --- a/workspace-server/cmd/server/main.go +++ b/workspace-server/cmd/server/main.go @@ -297,6 +297,15 @@ func main() { registry.StartHibernationMonitor(c, wh.HibernateWorkspace) }) + // RFC #2829 PR-3: stuck-task sweeper for the durable delegations + // ledger. Marks deadline-exceeded rows as failed and heartbeat-stale + // in-flight rows as stuck. Both transitions go through the ledger's + // terminal forward-only protection so concurrent UpdateStatus calls + // are not clobbered. Defaults: 5min interval, 10min stale threshold; + // override via DELEGATION_SWEEPER_INTERVAL_S / DELEGATION_STUCK_THRESHOLD_S. + delegSweeper := handlers.NewDelegationSweeper(nil, nil) + go supervised.RunWithRecover(ctx, "delegation-sweeper", delegSweeper.Start) + // Channel Manager — social channel integrations (Telegram, Slack, etc.) channelMgr := channels.NewManager(wh, broadcaster) go supervised.RunWithRecover(ctx, "channel-manager", channelMgr.Start) diff --git a/workspace-server/internal/router/admin_delegations_route_test.go b/workspace-server/internal/router/admin_delegations_route_test.go new file mode 100644 index 00000000..062b6967 --- /dev/null +++ b/workspace-server/internal/router/admin_delegations_route_test.go @@ -0,0 +1,78 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/handlers" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/middleware" + "github.com/gin-gonic/gin" +) + +// admin_delegations_route_test.go — pin the RFC #2829 PR-4 wiring. +// +// Both the List and Stats endpoints must: +// 1. Be registered at the documented path +// 2. Be gated by AdminAuth (caller without a valid admin token → 401) +// +// Without this gate test, a future router refactor could silently drop +// AdminAuth on these endpoints — the operator dashboard would still work +// for the operator, but unauthenticated callers could pull the in-flight +// delegation list including caller_id, callee_id, and task previews. + +func buildAdminDelegationsEngine(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + adH := handlers.NewAdminDelegationsHandler(db.DB) + r.GET("/admin/delegations", middleware.AdminAuth(db.DB), adH.List) + r.GET("/admin/delegations/stats", middleware.AdminAuth(db.DB), adH.Stats) + return r +} + +// Both tests use the existing AdminAuth pattern: set ADMIN_TOKEN to disable +// the dev-mode fail-open branch, and have HasAnyLiveTokenGlobal return ≥1 +// so AdminAuth enforces auth (rather than fail-open on fresh install). +// Without these two switches AdminAuth would return 200 + invoke the +// handler — defeating the gate test. + +func TestAdminDelegationsRoute_List_RequiresAdminAuth(t *testing.T) { + t.Setenv("ADMIN_TOKEN", "test-admin-secret-not-presented-by-caller") + mock := setupRouterTestDB(t) + mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + r := buildAdminDelegationsEngine(t) + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/admin/delegations", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 for unauthenticated request, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock unmet: %v", err) + } +} + +func TestAdminDelegationsRoute_Stats_RequiresAdminAuth(t *testing.T) { + t.Setenv("ADMIN_TOKEN", "test-admin-secret-not-presented-by-caller") + mock := setupRouterTestDB(t) + mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + r := buildAdminDelegationsEngine(t) + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/admin/delegations/stats", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 for unauthenticated request, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock unmet: %v", err) + } +} diff --git a/workspace-server/internal/router/router.go b/workspace-server/internal/router/router.go index 1afff092..59403cae 100644 --- a/workspace-server/internal/router/router.go +++ b/workspace-server/internal/router/router.go @@ -433,6 +433,15 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi r.POST("/admin/a2a-queue/drop-stale", middleware.AdminAuth(db.DB), qH.DropStale) } + // Admin — RFC #2829 PR-4 dashboard endpoints over the durable + // `delegations` ledger (PR-1 schema). Operators triage in-flight, + // stuck, or failed delegations without direct DB access. + { + adH := handlers.NewAdminDelegationsHandler(db.DB) + r.GET("/admin/delegations", middleware.AdminAuth(db.DB), adH.List) + r.GET("/admin/delegations/stats", middleware.AdminAuth(db.DB), adH.Stats) + } + // Admin — workspace template image refresh. Pulls latest images from GHCR // and recreates running ws-* containers so they adopt the new image. // Final step of the runtime CD chain — see docs/workspace-runtime-package.md.