From 70678cb352b689dfdd1bd20f6bc3bf69f53cf3c6 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Thu, 26 Mar 2026 19:38:02 +0000 Subject: [PATCH] Feat default surface routing + fix startup hang (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default surface routing (v0.2.1): - `/` redirects to configurable default surface with fallback chain: configured → first enabled extension surface → /admin - Auto-sets default_surface on first extension surface install - Admin settings UI: Default Surface dropdown - disabledRedirect() and extension surface disabled redirect now go to /admin instead of / to prevent redirect loops Fix Gin route param conflict causing backend startup hang: - Team package settings routes used `:pkgId` while sibling routes used `:id` — Gin's radix tree entered infinite loop on conflicting param names. Unified to `:id`. Docker entrypoint hardening: - Health check timeout 10s → 60s - Stale backend process cleanup on restart - Crash detection (exit early if backend dies) Roadmap direction updates: - v0.3.0: Notes surface (Obsidian-style) replaces Editor surface - v0.4.0: MVP (was v0.5.0) — chat moved to post-MVP - No built-in auto-install — explicit install only Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 30 +++++++++- ROADMAP.md | 32 +++++----- docker-entrypoint.sh | 16 ++++- server/handlers/packages.go | 12 ++++ server/handlers/team_package_settings.go | 12 ++-- server/main.go | 12 ++-- server/pages/pages.go | 2 +- server/pages/pages_surfaces.go | 74 +++++++++++++++++++++++- src/js/sw/surfaces/admin/settings.js | 19 +++++- 9 files changed, 169 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35a778e..62248cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,35 @@ All notable changes to Switchboard Core are documented here. -## [Unreleased] — v0.2.0 +## [Unreleased] — v0.2.1 + +### Added + +- **Default surface routing**: `/` redirects to configurable default surface. + Fallback chain: configured default → first enabled extension surface → `/admin`. + First installed extension surface auto-becomes default. Admin can change via + Settings > Default Surface dropdown. +- `default_surface` global config key (JSON `{"id": "slug"}`) +- Admin settings UI: Default Surface dropdown (extension surfaces only) + +### Changed + +- `disabledRedirect()` now redirects to `/admin` instead of `/` to prevent + redirect loops when the default surface is disabled +- Disabled extension surfaces (`/s/:slug`) redirect to `/admin` instead of `/` + +### Fixed + +- **Gin route param conflict** causing backend startup hang: team-scoped + package settings routes used `:pkgId` while sibling routes used `:id`. + Gin's radix tree entered an infinite loop on the conflicting param names. + Unified to `:id` across all `/teams/:teamId/packages/` routes. +- Docker entrypoint: increased health check timeout (10s → 60s), added stale + process cleanup and crash detection to prevent zombie backends on restart + +--- + +## [v0.2.0] — 2026-03-26 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index a46a1dd..2ac4814 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -48,7 +48,7 @@ SDK stabilization, and the first rebuilt extension (tasks). | Step | Status | Description | |------|--------|-------------| -| Default surface routing | 🔲 | `/` redirects to configurable default surface. No surfaces → admin. First install becomes default. Changeable in admin settings. | +| Default surface routing | ✅ | `/` redirects to configurable default surface. No surfaces → admin. First install becomes default. Changeable in admin settings. | | ICD (API contract) | 🔲 | OpenAPI spec from registered routes, kernel-only endpoint docs | ### v0.2.2 — Event Bus + Triggers @@ -65,42 +65,35 @@ SDK stabilization, and the first rebuilt extension (tasks). | SDK stabilization | 🔲 | `sb.slots()`, `sb.actions`, `sb.api.ext()`, `sb.storage`, theme tokens, primitive UI | | Task extension | 🔲 | First proof-of-concept — full task system rebuilt as Starlark extension using triggers + ext_data + notifications | -## v0.3.0 — Editor Surface +## v0.3.0 — Notes Surface -The code/markdown editor rebuilt as an installable surface package. +Obsidian-style rich-text notes rebuilt as an installable surface package. Zero platform special-casing. Proves the full extension stack E2E. -- Editor as `.pkg` archive -- CM6 integration via surface viewport -- File tree, tabs, preview pane — all extension-provided +- Notes as `.pkg` archive +- Rich text editor (ProseMirror or similar) +- Folder tree, backlinks, tags — all extension-provided +- Markdown import/export -## v0.4.0 — Chat Extension - -The AI chat system rebuilt as an installable extension package. - -- Provider registry as extension (BYOK chain, model catalog) -- Completion streaming via Starlark `provider.complete` -- Personas as extension data -- Channel/message storage via ext_data tables -- Tool system as extension hooks - -## v0.5.0 — MVP +## v0.4.0 — MVP Extension and operations tracks converge. First externally usable release. - Package registry (browse, install, update, uninstall) -- Extension marketplace foundations +- Package distribution model (no auto-install; explicit install only) - Health monitoring dashboard - Backup/restore tooling - Documentation site ## Post-MVP +- Chat extension (provider registry, streaming, personas, tool system) - Rich media extensions: image generation, code sandbox, STT/TTS - Desktop app (Tauri or Electron) - Sidecar tier: container-based extensions - Federation: cross-instance package sharing - Plugin marketplace with signing and review +- Mermaid diagrams extension (nice-to-have) ## Design Decisions Log @@ -116,3 +109,6 @@ Extension and operations tracks converge. First externally usable release. | Admin → RBAC group | The `role` column is pre-RBAC. v0.2.0 replaces it with a seeded "Admins" group + `surface.admin.access` grant. All users auto-join "Everyone" group. Admin middleware becomes a grant check, not a role check. | | Settings cascade | RBAC controls scope auth (who can set at what level). `user_overridable` flag controls whether lower scopes can override higher. Two orthogonal axes, composes cleanly with extension manifests. | | No new migrations pre-MVP | Edit existing migration SQL files in place. No migration chains until schema is in production. | +| Notes over Editor | First surface is Obsidian-style notes (rich text, folders, backlinks) instead of a code editor. Notes is a stronger E2E proof — it exercises ext_data, storage, and the SDK more fully than a pure-browser CM6 editor. | +| No built-in auto-install | Extensions ship in the repo but are not auto-installed. Distribution model TBD — explicit install only. Keeps the kernel clean and avoids opinionated defaults. | +| Chat → post-MVP | Chat extension (providers, streaming, personas) is valuable but not MVP-critical. The platform must prove itself with simpler surfaces first. Chat moves to post-MVP track. | diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 413a96a..473c10c 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -22,19 +22,29 @@ fi echo "🔀 Starting Switchboard Core backend..." +# Kill any stale backend from a previous entrypoint run +pkill -f /usr/local/bin/switchboard 2>/dev/null || true +sleep 0.2 + # Launch Go backend in background (from /app so migrations are found) cd /app /usr/local/bin/switchboard & BACKEND_PID=$! -# Wait for backend to be ready (max 10s) -for i in $(seq 1 20); do +# Wait for backend to be ready (max 60s) +for i in $(seq 1 120); do if wget -q --spider http://localhost:${PORT:-8080}${BASE_PATH}/health 2>/dev/null; then echo "✅ Backend ready (PID ${BACKEND_PID})" exit 0 fi + # Check backend didn't crash + if ! kill -0 ${BACKEND_PID} 2>/dev/null; then + echo "❌ Backend process died" + exit 1 + fi sleep 0.5 done -echo "❌ Backend failed to start within 10s" +echo "❌ Backend failed to start within 60s" +kill ${BACKEND_PID} 2>/dev/null || true exit 1 diff --git a/server/handlers/packages.go b/server/handlers/packages.go index 789edb0..d3b29a1 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -553,6 +553,18 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps)) } + // v0.2.1: Auto-set default_surface when the first extension surface is installed. + if (pkgType == "surface" || pkgType == "full") && pkgSource != "core" { + if raw, err := h.stores.GlobalConfig.Get(c.Request.Context(), "default_surface"); err != nil || raw == nil { + dflt := models.JSONMap{"id": pkgID} + if setErr := h.stores.GlobalConfig.Set(c.Request.Context(), "default_surface", dflt, ""); setErr != nil { + log.Printf("[packages] failed to auto-set default_surface to %s: %v", pkgID, setErr) + } else { + log.Printf("[packages] Auto-set default_surface to %s (first extension surface)", pkgID) + } + } + } + c.JSON(http.StatusOK, gin.H{ "id": pkgID, "title": title, diff --git a/server/handlers/team_package_settings.go b/server/handlers/team_package_settings.go index cfccd09..18032aa 100644 --- a/server/handlers/team_package_settings.go +++ b/server/handlers/team_package_settings.go @@ -26,10 +26,10 @@ func NewTeamPackageSettingsHandler(s store.Stores) *TeamPackageSettingsHandler { // GetTeamPackageSettings returns the team-scoped settings for a package, // along with the overridable schema keys for the UI. -// GET /api/v1/teams/:teamId/packages/:pkgId/settings +// GET /api/v1/teams/:teamId/packages/:id/settings func (h *TeamPackageSettingsHandler) GetTeamPackageSettings(c *gin.Context) { teamID := c.Param("teamId") - pkgID := c.Param("pkgId") + pkgID := c.Param("id") pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID) if err != nil || pkg == nil { @@ -60,10 +60,10 @@ func (h *TeamPackageSettingsHandler) GetTeamPackageSettings(c *gin.Context) { // UpdateTeamPackageSettings saves team-scoped overrides for a package. // Non-overridable keys are stripped before saving. -// PUT /api/v1/teams/:teamId/packages/:pkgId/settings +// PUT /api/v1/teams/:teamId/packages/:id/settings func (h *TeamPackageSettingsHandler) UpdateTeamPackageSettings(c *gin.Context) { teamID := c.Param("teamId") - pkgID := c.Param("pkgId") + pkgID := c.Param("id") pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID) if err != nil || pkg == nil { @@ -108,10 +108,10 @@ func (h *TeamPackageSettingsHandler) UpdateTeamPackageSettings(c *gin.Context) { } // DeleteTeamPackageSettings removes team-scoped overrides for a package. -// DELETE /api/v1/teams/:teamId/packages/:pkgId/settings +// DELETE /api/v1/teams/:teamId/packages/:id/settings func (h *TeamPackageSettingsHandler) DeleteTeamPackageSettings(c *gin.Context) { teamID := c.Param("teamId") - pkgID := c.Param("pkgId") + pkgID := c.Param("id") if err := h.stores.Packages.DeleteTeamSettings(c.Request.Context(), pkgID, teamID); err != nil { c.JSON(500, gin.H{"error": "failed to delete team settings"}) diff --git a/server/main.go b/server/main.go index a21621a..1faef1a 100644 --- a/server/main.go +++ b/server/main.go @@ -478,9 +478,9 @@ func main() { // Team package settings — cascade overrides (v0.2.0) teamPkgSettingsH := handlers.NewTeamPackageSettingsHandler(stores) - teamScoped.GET("/packages/:pkgId/settings", teamPkgSettingsH.GetTeamPackageSettings) - teamScoped.PUT("/packages/:pkgId/settings", teamPkgSettingsH.UpdateTeamPackageSettings) - teamScoped.DELETE("/packages/:pkgId/settings", teamPkgSettingsH.DeleteTeamPackageSettings) + teamScoped.GET("/packages/:id/settings", teamPkgSettingsH.GetTeamPackageSettings) + teamScoped.PUT("/packages/:id/settings", teamPkgSettingsH.UpdateTeamPackageSettings) + teamScoped.DELETE("/packages/:id/settings", teamPkgSettingsH.DeleteTeamPackageSettings) // Team audit log (team admins only — RequireTeamAdmin on group) teamScoped.GET("/audit", teams.ListTeamAuditLog) @@ -683,10 +683,8 @@ func main() { pages.SetVersion(Version) pageEngine := pages.New(cfg, stores) - // Root redirect → default surface (admin for now, configurable in v0.2.0) - base.GET("/", func(c *gin.Context) { - c.Redirect(http.StatusTemporaryRedirect, cfg.BasePath+"/admin") - }) + // Root redirect → configurable default surface (v0.2.1) + base.GET("/", pageEngine.DefaultSurfaceRedirect()) // Login page — no auth required base.GET("/login", pageEngine.RenderLogin()) diff --git a/server/pages/pages.go b/server/pages/pages.go index 3328921..19dde05 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -391,7 +391,7 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc { return } if !sr.Enabled { - c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/") + c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/admin") return } if sr.Source == "core" { diff --git a/server/pages/pages_surfaces.go b/server/pages/pages_surfaces.go index 29cadb3..b7214cf 100644 --- a/server/pages/pages_surfaces.go +++ b/server/pages/pages_surfaces.go @@ -90,9 +90,77 @@ func (e *Engine) EnabledSurfaceIDs() []string { return ids } -// disabledRedirect returns a handler that redirects to the root. -func (e *Engine) disabledRedirect() gin.HandlerFunc { +// DefaultSurfaceRedirect returns a handler for GET / that redirects to the +// configured default surface, falling back to the first enabled extension +// surface, then /admin. +func (e *Engine) DefaultSurfaceRedirect() gin.HandlerFunc { return func(c *gin.Context) { - c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/") + target := e.resolveDefaultSurface(c.Request.Context()) + c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+target) + } +} + +// resolveDefaultSurface returns the path to redirect to (without BasePath). +// Priority: configured default_surface → first enabled extension surface → /admin. +func (e *Engine) resolveDefaultSurface(ctx context.Context) string { + if e.stores.GlobalConfig == nil || e.stores.Packages == nil { + return "/admin" + } + + // 1. Check configured default_surface + if raw, err := e.stores.GlobalConfig.Get(ctx, "default_surface"); err == nil && raw != nil { + if id, ok := raw["id"].(string); ok && id != "" { + if path := e.surfacePath(ctx, id); path != "" { + return path + } + // Configured surface is missing or disabled — fall through + } + } + + // 2. First enabled extension surface + surfaces, err := e.stores.Packages.ListEnabledByType(ctx, "surface") + if err == nil { + for _, s := range surfaces { + if s.Source == "core" { + continue + } + if s.Enabled { + return "/s/" + s.ID + } + } + } + + // 3. Fallback + return "/admin" +} + +// surfacePath returns the URL path for a surface ID, or "" if the surface +// doesn't exist or is disabled. +func (e *Engine) surfacePath(ctx context.Context, id string) string { + pkg, err := e.stores.Packages.Get(ctx, id) + if err != nil || pkg == nil || !pkg.Enabled { + return "" + } + + // Core surfaces live at /{id}, extension surfaces at /s/{slug} + if pkg.Source == "core" { + // Look up the core surface's base route + if sm := e.GetSurface(id); sm != nil { + // Use the first alt route (without params) or strip params from Route + if len(sm.AltRoutes) > 0 { + return sm.AltRoutes[0] + } + } + return "/" + id + } + return "/s/" + id +} + +// disabledRedirect returns a handler that redirects to /admin. +// Uses /admin (not /) to avoid a redirect loop when the default surface +// is the one being disabled. +func (e *Engine) disabledRedirect() gin.HandlerFunc { + return func(c *gin.Context) { + c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/admin") } } diff --git a/src/js/sw/surfaces/admin/settings.js b/src/js/sw/surfaces/admin/settings.js index 43111cc..0e6dcb8 100644 --- a/src/js/sw/surfaces/admin/settings.js +++ b/src/js/sw/surfaces/admin/settings.js @@ -7,21 +7,25 @@ const { useState, useEffect, useCallback } = hooks; export default function SettingsSection() { const [cfg, setCfg] = useState({}); const [models, setModels] = useState([]); + const [surfaces, setSurfaces] = useState([]); const [vault, setVault] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const load = useCallback(async () => { try { - const [s, m, v] = await Promise.all([ + const [s, m, v, surfList] = await Promise.all([ sw.api.admin.settings.get(), sw.api.admin.models.list().catch(() => []), sw.api.admin.vault.status().catch(() => null), + sw.api.admin.surfaces.list().catch(() => []), ]); const raw = s || {}; const pol = raw.policies || {}; const cfg_ = raw.settings || {}; + setSurfaces((surfList || []).filter(s => s.enabled)); setCfg({ + default_surface: cfg_.default_surface?.id || '', allow_registration: pol.allow_registration === 'true', registration_default_state: cfg_.registration_default_state || 'pending', system_prompt: cfg_.system_prompt || '', @@ -62,6 +66,9 @@ export default function SettingsSection() { async function save() { setSaving(true); try { + // Default surface + await sw.api.admin.settings.update('default_surface', { value: cfg.default_surface ? { id: cfg.default_surface } : null }); + // Policies await sw.api.admin.settings.update('allow_registration', { value: String(cfg.allow_registration) }); await sw.api.admin.settings.update('registration_default_state', { value: cfg.registration_default_state }); @@ -128,6 +135,16 @@ export default function SettingsSection() { return html`
+

Default Surface

+
+ +
+ Where users land when visiting /. Only extension surfaces are listed. +
+

Registration

-- 2.49.1