Compare commits
3 Commits
main
...
31d72b2938
| Author | SHA1 | Date | |
|---|---|---|---|
| 31d72b2938 | |||
| 0364fbb1a9 | |||
| 6fec66bcc1 |
49
CHANGELOG.md
49
CHANGELOG.md
@@ -2,6 +2,55 @@
|
|||||||
|
|
||||||
All notable changes to Switchboard Core are documented here.
|
All notable changes to Switchboard Core are documented here.
|
||||||
|
|
||||||
|
## v0.3.7 — Package Audit
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Dormant status**: New `dormant` value in `packages.status` CHECK constraint
|
||||||
|
(both Postgres and SQLite). Installer auto-detects packages with unmet
|
||||||
|
`requires` entries and sets `status=dormant, enabled=false`. Enable endpoint
|
||||||
|
returns 409 for dormant packages.
|
||||||
|
- **Generalized requires check**: Any unmet requirement triggers dormant status,
|
||||||
|
not just specific values. Known capabilities checked against a whitelist;
|
||||||
|
anything unknown is unmet.
|
||||||
|
- **Admin UI indicators**: Dormant badge, disabled Enable button with tooltip,
|
||||||
|
Dormant stat card in packages admin view.
|
||||||
|
- **Tests**: 4 new handler tests covering dormant status, enable blocking,
|
||||||
|
surface exclusion, and admin list inclusion.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Manifest fixes**: Fixed 6 chat-extension manifests (`"name"` → `"title"`)
|
||||||
|
that were blocking install. Added explicit `"type": "surface"` to
|
||||||
|
hello-dashboard, icd-test-runner, sdk-test-runner.
|
||||||
|
- **Dashboard + Editor tagged dormant**: Both depend on removed `sw.*`
|
||||||
|
imperative SDK (`sw.tabs`, `sw.chat`, `sw.layout`, etc.) — render blank.
|
||||||
|
Tagged with `requires: ["legacy-sdk"]` so installer auto-sets dormant.
|
||||||
|
- **Dependency check**: Relaxed library dependency validation to allow
|
||||||
|
`pending_review` libraries (gitea-client declares permissions). Only
|
||||||
|
`suspended`/`dormant` libraries blocked.
|
||||||
|
|
||||||
|
### Package Audit Results
|
||||||
|
|
||||||
|
- **7 working surfaces**: schedules, tasks, team-activity-log, git-board,
|
||||||
|
hello-dashboard, icd-test-runner, sdk-test-runner
|
||||||
|
- **8 dormant**: 6 chat-dependent (csv-table, diff-viewer, js-sandbox,
|
||||||
|
katex-renderer, mermaid-renderer, regex-tester) + 2 legacy-sdk (dashboard,
|
||||||
|
editor)
|
||||||
|
- **1 library**: gitea-client (standalone, active)
|
||||||
|
|
||||||
|
## v0.3.6 — Demo Workflows + Interactive Demo
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **4 example workflow packages**: bug-report-triage, content-approval,
|
||||||
|
employee-onboarding, webhook-notifier — installable via admin API.
|
||||||
|
- **Demo surface**: `workflow-demo` package with interactive workflow browser,
|
||||||
|
install/uninstall, and guided walkthrough.
|
||||||
|
- **Engine context fix**: workflow engine now passes full context through
|
||||||
|
stage transitions.
|
||||||
|
- **SLA installer fix**: SLA scanner goroutine startup race condition resolved.
|
||||||
|
|
||||||
## v0.3.5 — Settings Audit + ICD + Tests + Clone
|
## v0.3.5 — Settings Audit + ICD + Tests + Clone
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
17
ROADMAP.md
17
ROADMAP.md
@@ -1,6 +1,6 @@
|
|||||||
# Switchboard Core — Roadmap
|
# Switchboard Core — Roadmap
|
||||||
|
|
||||||
## Current: v0.2.0 — SDK & Triggers
|
## Current: v0.3.7 — Package Audit
|
||||||
|
|
||||||
Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat
|
Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat
|
||||||
features removed from the kernel. What remains is the minimum viable
|
features removed from the kernel. What remains is the minimum viable
|
||||||
@@ -194,16 +194,19 @@ See `docs/DEMO-WORKFLOWS.md` for full stage definitions and Starlark code.
|
|||||||
| Engine context fix | | Add `started_by` to automated stage Starlark context dict (backward-compatible). |
|
| Engine context fix | | Add `started_by` to automated stage Starlark context dict (backward-compatible). |
|
||||||
| Documentation | | `docs/DEMO-WORKFLOWS.md`: feature capability matrix, stage flows, API walkthrough, per-package READMEs. |
|
| Documentation | | `docs/DEMO-WORKFLOWS.md`: feature capability matrix, stage flows, API walkthrough, per-package READMEs. |
|
||||||
|
|
||||||
### v0.3.7 — Package Audit
|
### v0.3.7 — Package Audit (complete)
|
||||||
|
|
||||||
Verify all non-chat packages install and render correctly.
|
Verified all 16 packages install correctly. Packages with unmet `requires` auto-set to dormant.
|
||||||
Tag chat-dependent packages with `requires` and `status: "dormant"`.
|
|
||||||
|
|
||||||
| Step | Status | Description |
|
| Step | Status | Description |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| Browser package audit | | Install and verify all standalone packages (hello-dashboard, dashboard, schedules, editor, tasks, team-activity-log, test runners). |
|
| Manifest fixes | ✅ | Fixed 6 chat-extension manifests (`"name"` → `"title"`, was blocking install). Added explicit `"type": "surface"` to hello-dashboard, icd-test-runner, sdk-test-runner. |
|
||||||
| Chat-dependency tagging | | Add `"requires": ["chat"]` and `"status": "dormant"` to csv-table, diff-viewer, js-sandbox, katex-renderer, mermaid-renderer, regex-tester, git-board, gitea-client. |
|
| Dormant status | ✅ | Added `dormant` to `packages.status` CHECK constraint (both dialects). Installer auto-detects unmet `requires` and sets `status=dormant, enabled=false`. Enable endpoint returns 409 for dormant packages. |
|
||||||
| Fix broken packages | | Repair any install, rendering, or API issues found during audit. |
|
| Functional audit | ✅ | Navigated to every surface in browser. 7 working: schedules, tasks, team-activity-log, git-board, hello-dashboard, icd-test-runner, sdk-test-runner. 2 broken: dashboard + editor (depend on removed `sw.*` imperative SDK) — tagged `requires: ["legacy-sdk"]` → dormant. |
|
||||||
|
| Chat-dependency tagging | ✅ | 6 chat-dependent extensions (csv-table, diff-viewer, js-sandbox, katex-renderer, mermaid-renderer, regex-tester) install as dormant. git-board and gitea-client are standalone. |
|
||||||
|
| Dependency check fix | ✅ | Relaxed library dependency validation to allow `pending_review` libraries (gitea-client declares permissions). Only `suspended`/`dormant` libraries blocked. |
|
||||||
|
| Admin UI | ✅ | Dormant badge, disabled Enable button with tooltip, Dormant stat card in admin packages view. |
|
||||||
|
| Tests | ✅ | 4 handler tests (SetStatus dormant, enable blocked, surfaces exclude dormant, admin list includes dormant). All existing tests pass. |
|
||||||
|
|
||||||
### v0.3.8 — Distribution
|
### v0.3.8 — Distribution
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "csv-table",
|
"id": "csv-table",
|
||||||
"name": "CSV Table Viewer",
|
"title": "CSV Table Viewer",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "Switchboard Core",
|
"author": "Switchboard Core",
|
||||||
"icon": "📊",
|
"icon": "📊",
|
||||||
"description": "Project dashboard exercising all SDK primitives",
|
"description": "Project dashboard exercising all SDK primitives (requires legacy sw.* SDK — dormant until rewritten)",
|
||||||
|
"requires": ["legacy-sdk"],
|
||||||
"route": "/s/dashboard",
|
"route": "/s/dashboard",
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
"settings": [
|
"settings": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "diff-viewer",
|
"id": "diff-viewer",
|
||||||
"name": "Diff Viewer",
|
"title": "Diff Viewer",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "Switchboard Core",
|
"author": "Switchboard Core",
|
||||||
"icon": "✏️",
|
"icon": "✏️",
|
||||||
"description": "Code editor with workspace management, file tree, and AI assist",
|
"description": "Code editor with workspace management, file tree, and AI assist (requires legacy sw.* SDK — dormant until rewritten)",
|
||||||
|
"requires": ["legacy-sdk"],
|
||||||
"route": "/s/editor",
|
"route": "/s/editor",
|
||||||
"layout": "editor",
|
"layout": "editor",
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "hello-dashboard",
|
"id": "hello-dashboard",
|
||||||
"icon": "👋",
|
"icon": "👋",
|
||||||
|
"type": "surface",
|
||||||
"title": "Hello Dashboard",
|
"title": "Hello Dashboard",
|
||||||
"route": "/s/hello-dashboard",
|
"route": "/s/hello-dashboard",
|
||||||
"auth": "authenticated",
|
"auth": "authenticated",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "icd-test-runner",
|
"id": "icd-test-runner",
|
||||||
"icon": "🧪",
|
"icon": "🧪",
|
||||||
|
"type": "surface",
|
||||||
"title": "ICD Test Runner",
|
"title": "ICD Test Runner",
|
||||||
"route": "/s/icd-test-runner",
|
"route": "/s/icd-test-runner",
|
||||||
"auth": "authenticated",
|
"auth": "authenticated",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "js-sandbox",
|
"id": "js-sandbox",
|
||||||
"name": "JavaScript Sandbox",
|
"title": "JavaScript Sandbox",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "katex-renderer",
|
"id": "katex-renderer",
|
||||||
"name": "KaTeX Math",
|
"title": "KaTeX Math",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "mermaid-renderer",
|
"id": "mermaid-renderer",
|
||||||
"name": "Mermaid Diagrams",
|
"title": "Mermaid Diagrams",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "regex-tester",
|
"id": "regex-tester",
|
||||||
"name": "Regex Tester",
|
"title": "Regex Tester",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "sdk-test-runner",
|
"id": "sdk-test-runner",
|
||||||
"icon": "🔬",
|
"icon": "🔬",
|
||||||
|
"type": "surface",
|
||||||
"title": "SDK Test Runner",
|
"title": "SDK Test Runner",
|
||||||
"route": "/s/sdk-test-runner",
|
"route": "/s/sdk-test-runner",
|
||||||
"auth": "authenticated",
|
"auth": "authenticated",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS packages (
|
|||||||
manifest JSONB NOT NULL DEFAULT '{}',
|
manifest JSONB NOT NULL DEFAULT '{}',
|
||||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
status TEXT NOT NULL DEFAULT 'active'
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
CHECK (status IN ('active', 'pending_review', 'suspended')),
|
CHECK (status IN ('active', 'pending_review', 'suspended', 'dormant')),
|
||||||
schema_version INTEGER NOT NULL DEFAULT 0,
|
schema_version INTEGER NOT NULL DEFAULT 0,
|
||||||
package_settings JSONB NOT NULL DEFAULT '{}',
|
package_settings JSONB NOT NULL DEFAULT '{}',
|
||||||
source TEXT NOT NULL DEFAULT 'core'
|
source TEXT NOT NULL DEFAULT 'core'
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS packages (
|
|||||||
manifest TEXT NOT NULL DEFAULT '{}',
|
manifest TEXT NOT NULL DEFAULT '{}',
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
status TEXT NOT NULL DEFAULT 'active'
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
CHECK (status IN ('active', 'pending_review', 'suspended')),
|
CHECK (status IN ('active', 'pending_review', 'suspended', 'dormant')),
|
||||||
schema_version INTEGER NOT NULL DEFAULT 0,
|
schema_version INTEGER NOT NULL DEFAULT 0,
|
||||||
package_settings TEXT NOT NULL DEFAULT '{}',
|
package_settings TEXT NOT NULL DEFAULT '{}',
|
||||||
source TEXT NOT NULL DEFAULT 'core'
|
source TEXT NOT NULL DEFAULT 'core'
|
||||||
|
|||||||
232
server/handlers/package_dormant_test.go
Normal file
232
server/handlers/package_dormant_test.go
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
authpkg "switchboard-core/auth"
|
||||||
|
"switchboard-core/config"
|
||||||
|
"switchboard-core/database"
|
||||||
|
"switchboard-core/middleware"
|
||||||
|
"switchboard-core/store"
|
||||||
|
postgres "switchboard-core/store/postgres"
|
||||||
|
sqlite "switchboard-core/store/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Dormant Test Harness ──────────────────────
|
||||||
|
|
||||||
|
type dormantHarness struct {
|
||||||
|
*testHarness
|
||||||
|
adminToken string
|
||||||
|
adminID string
|
||||||
|
stores store.Stores
|
||||||
|
pkgH *PackageHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupDormantHarness(t *testing.T) *dormantHarness {
|
||||||
|
t.Helper()
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
database.TruncateAll(t)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
JWTSecret: testJWTSecret,
|
||||||
|
BasePath: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
var stores store.Stores
|
||||||
|
if database.IsSQLite() {
|
||||||
|
stores = sqlite.NewStores(database.TestDB)
|
||||||
|
} else {
|
||||||
|
stores = postgres.NewStores(database.TestDB)
|
||||||
|
}
|
||||||
|
userCache := middleware.NewUserStatusCache()
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
api := r.Group("/api/v1")
|
||||||
|
|
||||||
|
auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider())
|
||||||
|
api.POST("/auth/register", auth.Register)
|
||||||
|
|
||||||
|
pkgH := NewPackageHandler(stores)
|
||||||
|
|
||||||
|
// Protected (authenticated) routes
|
||||||
|
protected := api.Group("")
|
||||||
|
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||||
|
protected.GET("/surfaces", pkgH.ListEnabledSurfaces)
|
||||||
|
|
||||||
|
// Admin routes
|
||||||
|
admin := api.Group("/admin")
|
||||||
|
admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin(stores))
|
||||||
|
admin.GET("/packages", pkgH.ListPackages)
|
||||||
|
admin.PUT("/packages/:id/enable", pkgH.EnablePackage)
|
||||||
|
admin.PUT("/packages/:id/disable", pkgH.DisablePackage)
|
||||||
|
|
||||||
|
// Seed admin user
|
||||||
|
adminID := seedInsertReturningID(t,
|
||||||
|
`INSERT INTO users (username, email, password_hash, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||||
|
"dorm-admin", "dorm-admin@test.com", "$2a$10$test", "dorm-admin", "builtin",
|
||||||
|
)
|
||||||
|
database.SeedEveryoneGroupMember(t, adminID)
|
||||||
|
database.SeedAdminsGroupMember(t, adminID)
|
||||||
|
adminToken := makeToken(adminID, "dorm-admin@test.com", "admin")
|
||||||
|
|
||||||
|
return &dormantHarness{
|
||||||
|
testHarness: &testHarness{router: r, t: t},
|
||||||
|
adminToken: adminToken,
|
||||||
|
adminID: adminID,
|
||||||
|
stores: stores,
|
||||||
|
pkgH: pkgH,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedPackage inserts a package directly into the DB for testing.
|
||||||
|
func (h *dormantHarness) seedPackage(id, title, pkgType, status string, enabled bool, manifest map[string]any) {
|
||||||
|
h.t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
manifestJSON, _ := json.Marshal(manifest)
|
||||||
|
|
||||||
|
q := dialectSQL(`INSERT INTO packages (id, title, type, version, tier, manifest, enabled, status, source)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`)
|
||||||
|
|
||||||
|
enabledInt := 0
|
||||||
|
if enabled {
|
||||||
|
enabledInt = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := database.TestDB.ExecContext(ctx, q,
|
||||||
|
id, title, pkgType, "1.0.0", "browser", string(manifestJSON), enabledInt, status, "extension",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatalf("seedPackage(%s): %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// Tests
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
// TestSetStatus_Dormant verifies the DB CHECK constraint accepts 'dormant'.
|
||||||
|
func TestSetStatus_Dormant(t *testing.T) {
|
||||||
|
h := setupDormantHarness(t)
|
||||||
|
|
||||||
|
h.seedPackage("status-test", "Status Test", "surface", "active", true, map[string]any{
|
||||||
|
"id": "status-test", "title": "Status Test", "type": "surface",
|
||||||
|
})
|
||||||
|
|
||||||
|
err := h.stores.Packages.SetStatus(context.Background(), "status-test", "dormant")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetStatus(dormant) failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := h.stores.Packages.Get(context.Background(), "status-test")
|
||||||
|
if err != nil || pkg == nil {
|
||||||
|
t.Fatalf("Get after SetStatus failed: %v", err)
|
||||||
|
}
|
||||||
|
if pkg.Status != "dormant" {
|
||||||
|
t.Errorf("status = %q, want %q", pkg.Status, "dormant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnableDormant_Blocked verifies the enable endpoint rejects dormant packages.
|
||||||
|
func TestEnableDormant_Blocked(t *testing.T) {
|
||||||
|
h := setupDormantHarness(t)
|
||||||
|
|
||||||
|
h.seedPackage("dormant-ext", "Dormant Extension", "extension", "dormant", false, map[string]any{
|
||||||
|
"id": "dormant-ext", "title": "Dormant Extension", "type": "extension", "requires": []string{"chat"},
|
||||||
|
})
|
||||||
|
|
||||||
|
w := h.request("PUT", "/api/v1/admin/packages/dormant-ext/enable", h.adminToken, nil)
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("enable dormant: want 409, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
decode(t, w, &resp)
|
||||||
|
errMsg, _ := resp["error"].(string)
|
||||||
|
if errMsg == "" {
|
||||||
|
t.Fatal("expected error message in response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDormantNotInSurfaces verifies dormant packages are excluded from the surfaces list.
|
||||||
|
func TestDormantNotInSurfaces(t *testing.T) {
|
||||||
|
h := setupDormantHarness(t)
|
||||||
|
|
||||||
|
// Active surface — should appear
|
||||||
|
h.seedPackage("active-surface", "Active Surface", "surface", "active", true, map[string]any{
|
||||||
|
"id": "active-surface", "title": "Active Surface", "type": "surface",
|
||||||
|
"route": "/s/active-surface",
|
||||||
|
})
|
||||||
|
// Dormant surface — should NOT appear
|
||||||
|
h.seedPackage("dormant-surface", "Dormant Surface", "full", "dormant", false, map[string]any{
|
||||||
|
"id": "dormant-surface", "title": "Dormant Surface", "type": "full",
|
||||||
|
"route": "/s/dormant-surface", "requires": []string{"chat"},
|
||||||
|
})
|
||||||
|
|
||||||
|
w := h.request("GET", "/api/v1/surfaces", h.adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("surfaces list: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
decode(t, w, &resp)
|
||||||
|
data, _ := resp["data"].([]interface{})
|
||||||
|
|
||||||
|
for _, item := range data {
|
||||||
|
m, _ := item.(map[string]interface{})
|
||||||
|
if m["id"] == "dormant-surface" {
|
||||||
|
t.Fatal("dormant surface should not appear in surfaces list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, item := range data {
|
||||||
|
m, _ := item.(map[string]interface{})
|
||||||
|
if m["id"] == "active-surface" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("active surface should appear in surfaces list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDormantInAdminList verifies dormant packages still appear in admin package list.
|
||||||
|
func TestDormantInAdminList(t *testing.T) {
|
||||||
|
h := setupDormantHarness(t)
|
||||||
|
|
||||||
|
h.seedPackage("vis-active", "Visible Active", "surface", "active", true, map[string]any{
|
||||||
|
"id": "vis-active", "title": "Visible Active", "type": "surface",
|
||||||
|
})
|
||||||
|
h.seedPackage("vis-dormant", "Visible Dormant", "extension", "dormant", false, map[string]any{
|
||||||
|
"id": "vis-dormant", "title": "Visible Dormant", "type": "extension", "requires": []string{"chat"},
|
||||||
|
})
|
||||||
|
|
||||||
|
w := h.request("GET", "/api/v1/admin/packages", h.adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("admin list: want 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
decode(t, w, &resp)
|
||||||
|
data, _ := resp["data"].([]interface{})
|
||||||
|
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for _, item := range data {
|
||||||
|
m, _ := item.(map[string]interface{})
|
||||||
|
id, _ := m["id"].(string)
|
||||||
|
ids[id] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ids["vis-active"] {
|
||||||
|
t.Error("active package missing from admin list")
|
||||||
|
}
|
||||||
|
if !ids["vis-dormant"] {
|
||||||
|
t.Error("dormant package missing from admin list")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,20 @@ func (h *PackageHandler) GetPackage(c *gin.Context) {
|
|||||||
// PUT /api/v1/admin/surfaces/:id/enable (alias)
|
// PUT /api/v1/admin/surfaces/:id/enable (alias)
|
||||||
func (h *PackageHandler) EnablePackage(c *gin.Context) {
|
func (h *PackageHandler) EnablePackage(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
|
// v0.3.7: Block enabling dormant packages (unmet requires).
|
||||||
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
||||||
|
if err != nil || pkg == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pkg.Status == "dormant" {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{
|
||||||
|
"error": "package is dormant — it requires capabilities not yet available (e.g. chat)",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, true); err != nil {
|
if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, true); err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||||
return
|
return
|
||||||
@@ -533,8 +547,8 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if lib.Status != "active" {
|
if lib.Status == "suspended" || lib.Status == "dormant" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not active: " + libID})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + libID})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
versionSpec, _ := vSpec.(string)
|
versionSpec, _ := vSpec.(string)
|
||||||
@@ -569,14 +583,38 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
// v0.3.7: Auto-set dormant for packages with unmet requires.
|
||||||
|
// Known capabilities: (none yet — chat and legacy-sdk are future/removed).
|
||||||
|
knownCaps := map[string]bool{}
|
||||||
|
var unmetReqs []string
|
||||||
|
if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 {
|
||||||
|
for _, r := range reqs {
|
||||||
|
req, _ := r.(string)
|
||||||
|
if req != "" && !knownCaps[req] {
|
||||||
|
unmetReqs = append(unmetReqs, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isDormant := len(unmetReqs) > 0
|
||||||
|
if isDormant {
|
||||||
|
h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant")
|
||||||
|
h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false)
|
||||||
|
preservedEnabled = false
|
||||||
|
log.Printf("[packages] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := gin.H{
|
||||||
"id": pkgID,
|
"id": pkgID,
|
||||||
"title": title,
|
"title": title,
|
||||||
"type": pkgType,
|
"type": pkgType,
|
||||||
"version": version,
|
"version": version,
|
||||||
"source": pkgSource,
|
"source": pkgSource,
|
||||||
"enabled": preservedEnabled,
|
"enabled": preservedEnabled,
|
||||||
})
|
}
|
||||||
|
if isDormant {
|
||||||
|
resp["status"] = "dormant"
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractableRelPath returns the relative path for a zip entry if it
|
// extractableRelPath returns the relative path for a zip entry if it
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type PackageStore interface {
|
|||||||
SetEnabled(ctx context.Context, id string, enabled bool) error
|
SetEnabled(ctx context.Context, id string, enabled bool) error
|
||||||
|
|
||||||
// SetStatus transitions a package's lifecycle status.
|
// SetStatus transitions a package's lifecycle status.
|
||||||
// Valid statuses: active, pending_review, suspended.
|
// Valid statuses: active, pending_review, suspended, dormant.
|
||||||
SetStatus(ctx context.Context, id string, status string) error
|
SetStatus(ctx context.Context, id string, status string) error
|
||||||
|
|
||||||
// Delete removes a non-core package. Core packages cannot be deleted.
|
// Delete removes a non-core package. Core packages cannot be deleted.
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ function sourceBadge(source) {
|
|||||||
return html`<span class="badge ${cls}">${source}</span>`;
|
return html`<span class="badge ${cls}">${source}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusBadge(status) {
|
||||||
|
if (status === 'dormant') return html`<span class="badge" style="opacity:0.6;">dormant</span>`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function PackagesSection() {
|
export default function PackagesSection() {
|
||||||
const [packages, setPackages] = useState([]);
|
const [packages, setPackages] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -56,6 +61,10 @@ export default function PackagesSection() {
|
|||||||
// ── Actions ─────────────────────────────────
|
// ── Actions ─────────────────────────────────
|
||||||
async function toggleEnabled(pkg) {
|
async function toggleEnabled(pkg) {
|
||||||
if (CORE_IDS.has(pkg.id)) return;
|
if (CORE_IDS.has(pkg.id)) return;
|
||||||
|
if (pkg.status === 'dormant') {
|
||||||
|
sw.toast('This package requires chat — it cannot be enabled yet', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (pkg.enabled) {
|
if (pkg.enabled) {
|
||||||
await sw.api.admin.packages.disable(pkg.id);
|
await sw.api.admin.packages.disable(pkg.id);
|
||||||
@@ -154,6 +163,7 @@ export default function PackagesSection() {
|
|||||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||||
|
|
||||||
const enabled = packages.filter(p => p.enabled).length;
|
const enabled = packages.filter(p => p.enabled).length;
|
||||||
|
const dormant = packages.filter(p => p.status === 'dormant').length;
|
||||||
const hasManifestSettings = (pkg) =>
|
const hasManifestSettings = (pkg) =>
|
||||||
pkg.manifest?.settings || pkg.package_settings;
|
pkg.manifest?.settings || pkg.package_settings;
|
||||||
|
|
||||||
@@ -163,6 +173,7 @@ export default function PackagesSection() {
|
|||||||
<div class="stat-cards-grid" style="margin-bottom:16px;">
|
<div class="stat-cards-grid" style="margin-bottom:16px;">
|
||||||
<div class="stat-card"><div class="stat-value">${packages.length}</div><div class="stat-label">Total</div></div>
|
<div class="stat-card"><div class="stat-value">${packages.length}</div><div class="stat-label">Total</div></div>
|
||||||
<div class="stat-card"><div class="stat-value">${enabled}</div><div class="stat-label">Enabled</div></div>
|
<div class="stat-card"><div class="stat-value">${enabled}</div><div class="stat-label">Enabled</div></div>
|
||||||
|
${dormant > 0 && html`<div class="stat-card"><div class="stat-value">${dormant}</div><div class="stat-label">Dormant</div></div>`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${/* ── Action bar ─────────────── */``}
|
${/* ── Action bar ─────────────── */``}
|
||||||
@@ -194,12 +205,14 @@ export default function PackagesSection() {
|
|||||||
${' '}${typeBadge(pkg.type)}
|
${' '}${typeBadge(pkg.type)}
|
||||||
${' '}${tierBadge(pkg.tier)}
|
${' '}${tierBadge(pkg.tier)}
|
||||||
${sourceBadge(pkg.source)}
|
${sourceBadge(pkg.source)}
|
||||||
|
${statusBadge(pkg.status)}
|
||||||
${pkg.is_system && html`<span class="badge" style="margin-left:4px;">system</span>`}
|
${pkg.is_system && html`<span class="badge" style="margin-left:4px;">system</span>`}
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-actions-cell">
|
<div class="admin-actions-cell">
|
||||||
<button class="btn-small"
|
<button class="btn-small"
|
||||||
onClick=${() => toggleEnabled(pkg)}
|
onClick=${() => toggleEnabled(pkg)}
|
||||||
disabled=${CORE_IDS.has(pkg.id)}>
|
disabled=${CORE_IDS.has(pkg.id) || pkg.status === 'dormant'}
|
||||||
|
title=${pkg.status === 'dormant' ? 'Requires chat' : ''}>
|
||||||
${pkg.enabled ? 'Disable' : 'Enable'}
|
${pkg.enabled ? 'Disable' : 'Enable'}
|
||||||
</button>
|
</button>
|
||||||
${hasManifestSettings(pkg) && html`
|
${hasManifestSettings(pkg) && html`
|
||||||
|
|||||||
Reference in New Issue
Block a user