server

package
v0.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 21, 2026 License: MIT Imports: 53 Imported by: 0

Documentation

Overview

build_services.go — factory for per-workspace WorkspaceServices.

Extracted from internal/cmd/serve.go as part of multi-tenant bcd phase M2. A single call to BuildWorkspaceServices(ctx, globals, wsRoot) produces a fully-initialized WorkspaceServices bundle including background goroutines. Its Close() cancels those goroutines and closes each store.

The factory depends ONLY on Globals + a workspace root path, so it can be invoked at any time for any registered workspace — which is the substrate for multi-workspace dispatch (phases M5-M6).

legacy_scope.go — 301 redirects for un-scoped UI paths.

The multi-workspace UI lives under /w/<wsId>/<page>. Older bookmarks hit /<page> directly and the SPA used to resolve them against the active workspace. After the /w/<wsId>/... canonicalization (see multi-workspace-and-code-tab.md §5.1 and §9.2–9.3) those un-scoped URLs should issue a permanent redirect to the active workspace's canonical URL, with Deprecation / Sunset headers so link-following clients can log the rename.

Scope: this middleware only rewrites the top-level SPA pages, not their subpaths that are served by /api/ or /_mcp/, which are handled by WorkspaceScope and the MCP dispatcher. If there is no active workspace in the registry we pass through — the SPA will show its "pick a workspace" CTA.

mcp_compat.go — backward-compat shim for pre-M6 MCP SSE URLs.

The canonical MCP endpoint lives at /_mcp/ws/<wsID>/<agent>/{sse,message} (see mcp_dispatch.go). Agents spawned before phase M6 were configured against the un-scoped /_mcp/<agent>/{sse,message} path, which was mounted directly against the launch workspace.

This middleware rewrites those legacy paths to the scoped form using the active workspace ID so the /_mcp/ws/ dispatcher serves them, and stamps Deprecation / Sunset response headers so anyone tailing logs can see which agent configurations still need updating. An internal URL rewrite is used rather than an HTTP 308 because streaming SSE clients are not reliably redirect-following on the initial GET.

Paths that are already scoped, or that address the MCP discovery / .well-known surface, pass through untouched. If there is no active workspace in the registry we also pass through — the existing legacy mount at /_mcp/<agent>/... continues to target the launch workspace as before.

mcp_dispatch.go — scoped MCP SSE routing for multi-workspace bcd.

Introduced in phase M6: the canonical MCP endpoint becomes

/_mcp/ws/<wsID>/<agent>/{sse,message}

dispatched per-request by the WorkspaceManager. Each loaded workspace maintains its own MCP server instance (built lazily on first scoped call). Legacy /_mcp/<agent>/* paths continue to target the launch workspace's server for backward compatibility with agents that were spawned before the path scheme changed.

Package server implements the bcd HTTP API server.

The server exposes workspace state over HTTP so the bc CLI can operate as a thin client. It binds to localhost only by default and serves:

  • REST API at /api/… (JSON, one handler file per resource)
  • SSE stream at /api/events (real-time agent state updates)
  • Static web UI at / (embedded web/dist, served when built)
  • Health probe at /health

Default address: 127.0.0.1:9374

stats_collector.go — per-workspace background metric collectors.

Split out of internal/cmd/serve.go in phase M2 so the factory in build_services.go can start these goroutines as part of a WorkspaceServices lifecycle. Behavior is identical to the prior implementation; only the package boundary changed.

workspace_scope.go implements URL-level workspace scoping middleware.

Overview ========

The bcd server historically exposes every resource at /api/<resource>/... bound to a single workspace. With multi-workspace support the proposal introduces two URL schemes:

/api/workspaces/{id}/<rest>     — scoped (canonical, post-migration)
/api/<rest>                     — legacy (active workspace shim)

The middleware in this file performs two jobs:

  1. Scoped rewrite: if the path matches /api/workspaces/{id}/<rest> AND {rest} is NOT empty AND NOT a registry-management route (detail, activate, etc.), rewrite the request's URL.Path to /api/<rest> and annotate the request context with the resolved WorkspaceServices. Existing handlers continue to use their closure-captured services (the active workspace's). If the {id} is the active workspace, this is a no-op rewrite and everything works. If {id} is a different registered workspace, we return 501 Not Implemented — full per- workspace handler dispatch is a future phase; switch via POST /api/workspaces/{id}/activate.

  2. Legacy headers: if the path starts with /api/ but NOT /api/workspaces, the response is annotated with Deprecation: true and a Sunset date so clients can warn when they are still using the old shape. The handler chain is unchanged (handlers serve the active workspace as before).

This split keeps the heavy refactor — per-workspace handler trees — for a later phase while delivering the URL surface specified in proposal §4.5.

Package server — workspace_services.go provides per-workspace service bundling and a lazy-loading manager so bcd can hold multiple workspaces open simultaneously.

The existing `Services` struct (server.go) is the flat dependency bundle used by the handler constructors. `WorkspaceServices` wraps that with a `Workspace` reference plus a closer so the manager can shut down a workspace cleanly on eviction.

Call flow:

bcd boot
  → NewWorkspaceManager(registry, factory)
  → mgr.LoadActive(ctx)          // eager-load the active workspace
  → handler: mgr.Get(id) or mgr.Load(ctx, id)  // lazy-load on demand
  → background goroutine every ~1m evicts idle entries after 30m

The factory is injected so internal/cmd/serve.go can supply the real initialization code (stats store, cron scheduler, gateway manager, etc.) without this package depending on it.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func LegacyMCPCompat added in v0.2.0

func LegacyMCPCompat(next http.Handler, mgr *WorkspaceManager) http.Handler

LegacyMCPCompat wraps next with a middleware that rewrites pre-M6 MCP URLs /_mcp/<agent>/{sse,message} to /_mcp/ws/<activeWsID>/<agent>/<action>.

Only two actions are remapped (sse, message) — anything else (including an empty agent segment) is forwarded to next so current non-MCP handlers and any future /_mcp/<something>/... additions aren't accidentally caught.

func LegacyUIScope added in v0.2.0

func LegacyUIScope(next http.Handler, mgr *WorkspaceManager) http.Handler

LegacyUIScope returns a middleware that 301-redirects legacy top-level SPA pages to their /w/<activeWsId>/<page> form. Paths the SPA already scopes (/w/...), API routes (/api/...), MCP routes (/_mcp/...), health probes, and static asset lookups pass through untouched.

If mgr is nil or there is no active workspace, the middleware is a no-op — the catch-all SPA handler will serve index.html as before.

func WebDist

func WebDist() fs.FS

WebDist returns the embedded web/dist filesystem, or nil when only placeholder files are present (i.e. the UI has not been built yet).

func WorkspaceIDFromContext added in v0.2.0

func WorkspaceIDFromContext(ctx context.Context) string

WorkspaceIDFromContext returns the scoped workspace ID set by the WorkspaceScope middleware, or an empty string if unset.

func WorkspaceScope added in v0.2.0

func WorkspaceScope(next http.Handler, mgr *WorkspaceManager) http.Handler

WorkspaceScope returns a middleware that:

  • rewrites /api/workspaces/{id}/<rest> → /api/<rest> when <rest> is a scoped resource (not a self-route), after resolving {id} to a loaded *WorkspaceServices.
  • annotates legacy /api/<rest> responses with Deprecation / Sunset.

The middleware is a no-op for any path that is not /api/... .

mgr may be nil — in which case scoped rewrites are disabled (registry CRUD still works since it's served by handlers registered directly on the mux). If mgr is non-nil but the {id} does not resolve, the request gets a 404.

Matching rules for scoped routes (see §4.5):

GET    /api/workspaces/{id}          -> registry detail (self route)
POST   /api/workspaces/{id}/activate -> registry action (self route)
* /api/workspaces/{id}/agents        -> scoped (rewrite)
* /api/workspaces/{id}/channels      -> scoped (rewrite)
* /api/workspaces/{id}/events        -> scoped (rewrite)
etc.

Types

type BuildInfo

type BuildInfo struct {
	Commit  string // short git commit hash
	BuiltAt string // UTC build timestamp (RFC 3339)
}

BuildInfo holds build-time metadata injected via ldflags.

type Config

type Config struct {
	Build      BuildInfo // build-time metadata
	Addr       string    // default "127.0.0.1:9374"
	CORSOrigin string    // allowed origin (default "*")
	APIKey     string    // optional API key for Bearer token auth (empty = disabled)
	CORS       bool      // enable permissive CORS headers (safe for loopback)
}

Config holds server configuration.

func DefaultConfig

func DefaultConfig() Config

DefaultConfig returns the default server configuration.

type Globals added in v0.2.0

type Globals struct {
	Registry     *bcworkspace.Registry
	Stats        *bcstats.Store     // nil when TSDB unavailable
	Deps         *bcdeps.Registry   // optional dependencies registry (bc-db, etc.)
	GlobalHub    *bcws.Hub          // fan-in SSE hub for cross-workspace /api/events
	Templates    *bctemplate.Store  // user-global template store (~/.bc/templates/) — wrapped per-workspace
	SecretsVault *bcsecret.Store    // user-global secrets vault (~/.bc/secrets.vault) — shared across workspaces
	MCPGlobal    *bcmcp.GlobalStore // user-global MCP registry (~/.bc/mcps.json)
	CostsGlobal  *cost.Store        // user-global cost ledger (~/.bc/costs.db) — shared across workspaces
	Build        BuildInfo
}

Globals holds dependencies that are truly workspace-agnostic and shared across all per-workspace services. bcd builds one Globals at boot and reuses it for every workspace the WorkspaceManager materializes.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is the bcd HTTP server.

func New

func New(cfg Config, svc Services, hub *ws.Hub, staticFiles fs.FS) *Server

New creates a bcd server with the given config, services, SSE hub, and optional static files.

func NewWithManager added in v0.2.0

func NewWithManager(cfg Config, mgr *WorkspaceManager, globals *Globals, staticFiles fs.FS) *Server

NewWithManager creates a bcd server using the multi-workspace primitives. It derives the launch-workspace Services bundle from mgr.Active() (for registered handlers that still need closure wiring) and wires the manager into the scope middleware. Phase M4: the canonical constructor going forward. `New` remains for tests that assemble Services directly.

func (*Server) Addr

func (s *Server) Addr() string

Addr returns the resolved listen address (updated after Start is called with :0).

func (*Server) Handler

func (s *Server) Handler() http.Handler

Handler returns the HTTP handler (useful for httptest.NewServer in tests).

func (*Server) Start

func (s *Server) Start(ctx context.Context) error

Start begins listening. It blocks until ctx is canceled or an error occurs.

type Services

type Services struct {
	Agents        *agent.AgentService
	Costs         *cost.Store
	CostImporter  *cost.Importer
	Cron          *cron.Store
	CronScheduler *cron.Scheduler
	Secrets       *secret.Store
	MCP           *mcp.Store
	Tools         *tool.Store
	Templates     *template.Store
	Stats         *stats.Store
	EventLog      events.EventStore
	EventWriter   *events.JSONLWriter
	WS            *workspace.Workspace
	Gateway       *gateway.Manager
	Notify        *notify.Service
	// Registry is the global workspace registry (~/.bc/workspaces.json).
	// Populated when bcd runs; exposed so the /api/workspaces handler can
	// list / add / activate entries.
	Registry *workspace.Registry
	// WorkspaceManager lazy-loads per-workspace services for scoped routes
	// at /api/workspaces/{id}/... — may be nil in tests.
	WorkspaceManager *WorkspaceManager
	// Deps is the optional dependencies registry (bc-db, bc-code-server,
	// bc-browser). May be nil in tests; when nil the /api/deps handler
	// returns an empty list and 404 for detail routes.
	Deps *deps.Registry
}

Services bundles all service/store dependencies for the handlers.

type WorkspaceFactory added in v0.2.0

type WorkspaceFactory func(ctx context.Context, ws *workspace.Workspace) (*WorkspaceServices, error)

WorkspaceFactory builds a fully-initialized WorkspaceServices for the given workspace root. Implementations should open all stores, start background loops, and return a closer that tears them down in reverse order.

type WorkspaceManager added in v0.2.0

type WorkspaceManager struct {
	// contains filtered or unexported fields
}

WorkspaceManager caches per-workspace services, lazy-loading on first access and evicting idle entries on a background loop.

func NewWorkspaceManager added in v0.2.0

func NewWorkspaceManager(registry *workspace.Registry, factory WorkspaceFactory) *WorkspaceManager

NewWorkspaceManager constructs a manager bound to the given registry and factory. Nothing is loaded until Load / LoadActive is called.

func (*WorkspaceManager) Active added in v0.2.0

func (m *WorkspaceManager) Active() *WorkspaceServices

Active returns the services for the active workspace if already loaded, else nil. Never triggers a load.

func (*WorkspaceManager) Close added in v0.2.0

func (m *WorkspaceManager) Close() error

Close tears down every loaded workspace. Safe to call multiple times.

func (*WorkspaceManager) Evict added in v0.2.0

func (m *WorkspaceManager) Evict(wsID string) error

Evict closes and removes the services for the given workspace ID.

func (*WorkspaceManager) Get added in v0.2.0

Get returns already-loaded services for the given workspace ID, or nil if the workspace is not currently loaded. It does NOT trigger a load.

func (*WorkspaceManager) List added in v0.2.0

func (m *WorkspaceManager) List() []*WorkspaceServices

List returns all currently-loaded workspace services.

func (*WorkspaceManager) Load added in v0.2.0

Load returns services for the given workspace ID, initializing them if necessary. Returns an error if the workspace is not in the registry or if the factory fails.

func (*WorkspaceManager) LoadActive added in v0.2.0

func (m *WorkspaceManager) LoadActive(ctx context.Context) (*WorkspaceServices, error)

LoadActive loads the registry's active workspace. Returns an error if no active workspace is set.

func (*WorkspaceManager) Registry added in v0.2.0

func (m *WorkspaceManager) Registry() *workspace.Registry

Registry returns the underlying workspace registry.

func (*WorkspaceManager) StartEvictionLoop added in v0.2.0

func (m *WorkspaceManager) StartEvictionLoop(ctx context.Context)

StartEvictionLoop launches a goroutine that periodically evicts workspaces whose LastAccess is older than the idle threshold. The loop exits when ctx is canceled or Close is called.

The registry's active workspace is never evicted so that legacy /api/... routes always find a default.

type WorkspaceServices added in v0.2.0

type WorkspaceServices struct {
	Services  Services // legacy flat bundle passed to existing handler constructors
	Workspace *workspace.Workspace

	// Per-workspace stores/services (populated by the factory in serve.go).
	// May be nil when the corresponding backend is unavailable in a given
	// workspace (e.g. secret store if passphrase missing).
	Agents       *agent.AgentService
	AgentMgr     *agent.Manager
	Channels     *notify.Service // bc currently co-locates channels in notify
	Events       events.EventStore
	EventWriter  *events.JSONLWriter
	Costs        *cost.Store
	CostImporter *cost.Importer
	Cron         *cron.Store
	CronSched    *cron.Scheduler
	Templates    *template.Store
	Secrets      *secret.Store
	MCP          *mcp.Store
	MCPGlobal    *mcp.GlobalStore // user-global MCP registry (~/.bc/mcps.json) — shared across workspaces
	Tools        *tool.Store
	Gateway      *gateway.Manager
	Notify       *notify.Service
	Hub          *ws.Hub
	// contains filtered or unexported fields
}

WorkspaceServices holds the complete set of per-workspace stores and managers for one workspace.

This struct is the unit of eviction: when the manager closes a workspace, it calls Close which tears down every service that was opened via the factory. The existing flat `Services` type (server.go) is kept on the struct so handler constructors that already accept *Services keep working during the transitional phases M1-M4.

Phase M1 widens the struct with named fields for every service a handler might need. Phase M3 has handlers start reading them via WorkspaceServicesFromContext. Phase M4 deletes the Services embed.

func BuildWorkspaceServices added in v0.2.0

func BuildWorkspaceServices(ctx context.Context, globals *Globals, wsRoot string) (*WorkspaceServices, error)

BuildWorkspaceServices constructs a fully-initialized WorkspaceServices for the workspace rooted at wsRoot. All background goroutines are started under an internal context that Close() cancels.

The returned *WorkspaceServices has its closer field set to a function that stops goroutines and closes stores. The caller (WorkspaceManager) will invoke Close() on eviction / shutdown.

func WorkspaceServicesFromContext added in v0.2.0

func WorkspaceServicesFromContext(ctx context.Context) *WorkspaceServices

WorkspaceServicesFromContext extracts the per-request *WorkspaceServices that WorkspaceScope stored under ctxKeyWorkspaceServices. Returns nil if the middleware did not fire (legacy route) or the scope was for the active workspace and no services handle was stored.

func (*WorkspaceServices) Close added in v0.2.0

func (ws *WorkspaceServices) Close() error

Close stops background goroutines started by the factory, waits for them to exit, then invokes the factory-supplied closer to tear down stores and close DB connections. Safe to call multiple times.

func (*WorkspaceServices) LastAccess added in v0.2.0

func (ws *WorkspaceServices) LastAccess() time.Time

LastAccess returns the last time Touch was called.

func (*WorkspaceServices) MCPLayeredView added in v0.2.0

func (ws *WorkspaceServices) MCPLayeredView() *bcmcp.LayeredView

MCPLayeredView returns a read-oriented composite of global + workspace MCP registries for the given WorkspaceServices. Callers use it to list / resolve servers with workspace-overrides winning. Returns nil when neither layer is available.

func (*WorkspaceServices) Touch added in v0.2.0

func (ws *WorkspaceServices) Touch()

Touch marks this workspace as recently used; prevents idle eviction.

Directories

Path Synopsis
code.go implements the read-only Code tab backend:
code.go implements the read-only Code tab backend:
Package mcp implements the Model Context Protocol server for bc workspaces.
Package mcp implements the Model Context Protocol server for bc workspaces.
Package ws implements a Server-Sent Events (SSE) hub for real-time event broadcasting to connected web clients.
Package ws implements a Server-Sent Events (SSE) hub for real-time event broadcasting to connected web clients.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL