From 7e4f1581f2c97b6fd9ff83d8550f00da3b63115c Mon Sep 17 00:00:00 2001 From: xcaliber Date: Tue, 10 Mar 2026 16:38:06 +0000 Subject: [PATCH] Changeset 0.27.0 (#166) --- VERSION | 2 +- docker-entrypoint-fe.sh | 4 + docs/DESIGN-0.27.0.md | 842 ++++++++++++++++++ docs/EXTENSION-SURFACES.md | 453 ++++++++++ docs/EXTENSIONS.md | 150 +--- docs/ROADMAP.md | 209 ++++- docs/SURFACES.md | 350 -------- hello-dashboard.surface | Bin 0 -> 3152 bytes nginx.conf | 17 + server/main.go | 23 +- server/pages/pages.go | 127 ++- server/pages/templates/base.html | 10 + server/pages/templates/surfaces/chat.html | 7 + .../pages/templates/surfaces/extension.html | 21 + src/css/extension-surface.css | 26 + 15 files changed, 1721 insertions(+), 520 deletions(-) create mode 100644 docs/DESIGN-0.27.0.md create mode 100644 docs/EXTENSION-SURFACES.md delete mode 100644 docs/SURFACES.md create mode 100644 hello-dashboard.surface create mode 100644 server/pages/templates/surfaces/extension.html create mode 100644 src/css/extension-surface.css diff --git a/VERSION b/VERSION index 5564d9a..81566e4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.26.5 +0.27.0 \ No newline at end of file diff --git a/docker-entrypoint-fe.sh b/docker-entrypoint-fe.sh index d6fcb97..8173e04 100644 --- a/docker-entrypoint-fe.sh +++ b/docker-entrypoint-fe.sh @@ -78,6 +78,10 @@ $(page_proxy_block "${BASE_PATH}/editor") $(page_proxy_block "${BASE_PATH}/notes") $(page_proxy_block "${BASE_PATH}/settings") $(page_proxy_block "${BASE_PATH}/w") + # v0.27.0: Extension surface page routes +$(page_proxy_block "${BASE_PATH}/s") + # v0.27.0: Extension surface static assets (served by backend from /data/surfaces/) +$(page_proxy_block "${BASE_PATH}/surfaces") ROUTES ) diff --git a/docs/DESIGN-0.27.0.md b/docs/DESIGN-0.27.0.md new file mode 100644 index 0000000..46f27cf --- /dev/null +++ b/docs/DESIGN-0.27.0.md @@ -0,0 +1,842 @@ +# DESIGN — v0.27.0+: Debt Clearance, Extension Surfaces, Tasks + +**Status:** Draft +**Scope:** Deferred debt clearance (v0.27.0), Tasks / Autonomous Agents (v0.27.x), TBD pull-forward (v0.28.0) +**Depends on:** Workflow Engine (v0.26.0), Dynamic Surfaces (v0.25.0), Permissions (v0.24.2) +**Current version:** 0.26.5 + +--- + +## Versioning Plan + +``` +v0.27.0 Debt Clearance: Extension Surface Routes + Workflow Polish + │ +v0.27.1 Tasks Foundation: Service Channels + Scheduler + │ +v0.27.2 Task Execution: Budgets + Admin Controls + │ +v0.27.3 Task Chaining: Webhooks + Workflow-to-Workflow + │ +v0.27.4 Personal Tasks: BYOK Scheduling + User Task UI + │ +v0.28.0 Platform Polish (TBD pull-forward) +``` + +--- + +## Code Audit — Items Already Shipped + +The ROADMAP lists these as deferred but the code proves they're done: + +| Item | Evidence | Listed as deferred in | +|------|----------|----------------------| +| Persona tool grant enforcement in completion handler | `completion.go:928–949` — second-pass allowlist via `GetToolGrants()` | v0.25.0, v0.26.0 | +| Version snapshot includes persona tool grants | `workflows.go:263–273` — `stageSnapshot.ToolGrants` populated at publish | v0.25.0 | +| Session cleanup job | Shipped in v0.26.0 (background goroutine, `SESSION_EXPIRY_DAYS`) | v0.24.3 | + +**Action:** Cross off all three in the ROADMAP's deferred sections and in +the v0.25.0 / v0.26.0 milestone checklists. + +--- + +## v0.27.0 — Debt Clearance + +All deferred items from v0.25.0 and v0.26.0 plus outstanding tech debt +that blocks future work. No new features — just finishing what's owed. + +### Phase 1: Extension Surface Routes (`/s/:slug`) + +The longest-deferred item (originally v0.21.3). Infrastructure exists: +`surface_registry` table, `InstallSurface` handler (zip upload + asset +extraction), `ListEnabledSurfaces` API. What's missing is the runtime +route mounting, template rendering, and frontend nav integration. + +**Problem: Hardcoded surface conditionals in `base.html`** + +Lines 61–66 and 118–122 of `base.html` use `{{if eq .Surface "chat"}}` +chains. Extension surfaces fall through to "Unknown surface". Same +pattern for CSS includes (line 25–26) and script includes (118–122). + +**Solution: Generic extension surface template + dynamic blocks** + +``` +base.html changes: + {{if eq .Surface "chat"}}... + {{else if eq .Surface "admin"}}... + ... + {{else if .Manifest}}{{template "surface-extension" .}} + {{else}}
Unknown surface: {{.Surface}}
+ {{end}} +``` + +New template `templates/surfaces/extension.html`: +```html +{{define "surface-extension"}} +
+ {{/* Extension JS mounts into this container */}} +
+
+{{end}} + +{{define "css-extension"}} +{{/* Extension CSS loaded dynamically from surfacesDir */}} +{{if .Manifest}} + +{{end}} +{{end}} + +{{define "scripts-extension"}} +{{if .Manifest}} + +{{end}} +{{end}} +``` + +**Dynamic route registration at startup:** + +```go +// pages.go — New() after registerCoreSurfaces(): +func (e *Engine) loadExtensionSurfaces() { + if e.stores.Surfaces == nil { + return + } + ctx := context.Background() + surfaces, err := e.stores.Surfaces.List(ctx) + if err != nil { + log.Printf("[pages] Failed to load extension surfaces: %v", err) + return + } + for _, sr := range surfaces { + if sr.Source == "core" { + continue // Core surfaces already registered + } + route, _ := sr.Manifest["route"].(string) + if route == "" { + route = "/s/" + sr.ID // Default to /s/:id + } + manifest := SurfaceManifest{ + ID: sr.ID, + Route: route, + Title: sr.Title, + Template: "surface-extension", + Auth: authFromManifest(sr.Manifest), // default "authenticated" + Layout: layoutFromManifest(sr.Manifest), + Source: "extension", + } + e.surfaces = append(e.surfaces, manifest) + } + log.Printf("[pages] Loaded %d extension surfaces from registry", + len(surfaces) - countCore(surfaces)) +} +``` + +**nginx config — serve extension static assets:** + +```nginx +# Extension surface assets +location /surfaces/ { + alias /data/surfaces/; + expires 1h; + add_header Cache-Control "public, immutable"; +} +``` + +**Frontend nav integration:** + +`ListEnabledSurfaces` API already returns route + title for all enabled +surfaces. The sidebar/nav needs to render extension surfaces as +additional items. Extension surfaces appear in a "Surfaces" section +of the sidebar, below the core nav items. + +```js +// app.js or pages.js — on init: +const surfaces = await App.api.get('/api/v1/surfaces'); +surfaces.filter(s => !['chat','admin','settings','editor','notes'].includes(s.id)) + .forEach(s => renderExtensionNavItem(s)); +``` + +**Extension surface manifest contract (`.surface` archive):** + +``` +my-dashboard.surface (zip) +├── manifest.json +├── js/ +│ └── main.js ← entry point, mounts into #extension-mount +├── css/ +│ └── main.css ← scoped styles +└── assets/ ← images, fonts, etc. +``` + +`manifest.json`: +```json +{ + "id": "my-dashboard", + "title": "Dashboard", + "route": "/s/dashboard", + "auth": "authenticated", + "layout": "single", + "components": ["chat-pane"], + "hooks": ["surface"] +} +``` + +The extension JS receives the standard `ctx` object (same extension API +from v0.11.0) plus access to platform components: + +```js +// main.js — extension surface entry point +(function() { + const mount = document.getElementById('extension-mount'); + const manifest = JSON.parse( + document.getElementById('extension-surface').dataset.manifest + ); + + // Full access to platform primitives + components + const chatPane = window.ChatPane?.create(mount, { role: 'assist' }); + + // Build custom UI + mount.innerHTML = '

My Dashboard

'; +})(); +``` + +**Admin UI additions:** + +The admin surfaces section (already exists) gains: +- Upload button (already wired to `InstallSurface`) +- Enable/disable toggles (already wired) +- Uninstall button (already wired to `DeleteSurface`) +- Route display showing the `/s/:slug` URL + +**Deliverables:** +- [ ] `surface-extension` template (HTML + CSS + scripts blocks) +- [ ] `base.html` conditional chain extended with `{{if .Manifest}}` fallthrough +- [ ] `loadExtensionSurfaces()` in page engine +- [ ] nginx location block for `/surfaces/` static assets +- [ ] Frontend nav renders extension surfaces from `ListEnabledSurfaces` +- [ ] Admin surfaces section shows upload/enable/disable/uninstall +- [ ] Sample `.surface` archive (hello-world dashboard) for testing +- [ ] CSP nonce propagation for extension scripts + + +### Phase 2: Workflow Engine Polish + +Eight items deferred from v0.26.0. All backend infrastructure exists; +these are wiring, UI, and enforcement. + +**Channel header stage indicator + advance/reject controls** +- Workflow channels show: stage name, step N of M, assignment info +- Advance / Reject buttons (permission-gated by `workflow.manage`) +- Renders in the chat header area (same slot as `ai_mode` context banner) + +**Stage persona auto-switch in chat UI** +- On stage transition, chat UI updates the active persona display +- Model selector reflects the stage persona's bound model +- System prompt injection already works (v0.26.4); this is FE-only + +**Assignment notifications via WebSocket** +- New workflow assignment → `workflow.assigned` WS event to team members +- Claim confirmation → `workflow.claimed` WS event to claimer +- Uses existing notification infrastructure (v0.20.0) + +**Round-robin auto-assignment** +- Per-stage `transition_rules.auto_assign: "round_robin"` in `workflow_stages` +- On stage entry: query team members, pick least-recently-assigned +- `workflow_assignments.assigned_to` populated automatically +- Falls back to unassigned pool if round-robin fails + +**`on_complete` workflow chaining** +- Column exists on `workflows` table (nullable JSONB, v0.26.1) +- Schema: `{"action": "start_workflow", "target_slug": "...", "data_map": {...}}` +- On workflow completion: if `on_complete` is non-null, start target workflow + with mapped `stage_data` from completed instance +- Reuses existing `StartInstance()` path + +**Workflow retention enforcement** +- Column exists on `workflows` table (`retention` JSONB, v0.26.1) +- Background sweep (extend staleness goroutine): completed instances + older than `retention.delete_after_days` → hard delete +- `retention.mode = "archive"` → set `workflow_status = 'archived'`, + `ai_mode = 'off'` (already implemented for channel archive) + +**Drag-and-drop stage reorder in builder** +- Backend `PATCH /api/v1/workflows/:id/stages/reorder` already exists +- Frontend: HTML5 drag events on stage list items in workflow builder +- Same DnD pattern as channel/folder reorder (v0.23.1) + +**Team-scoped workflow management UI** +- Team admin settings surface gains "Workflows" section +- Lists workflows owned by the team, quick-edit name/description +- Links to full builder for stage editing +- Mirrors the admin-level builder but scoped to `team_id` + +**Deliverables:** +- [ ] Channel header workflow stage indicator component +- [ ] Advance/reject buttons with permission gate +- [ ] Stage persona auto-switch in chat UI +- [ ] `workflow.assigned` / `workflow.claimed` WS events +- [ ] Round-robin auto-assignment in stage transition handler +- [ ] `on_complete` chaining trigger in workflow completion path +- [ ] Retention enforcement in staleness sweep goroutine +- [ ] Drag-and-drop stage reorder in workflow builder UI +- [ ] Team settings → Workflows section + + +### Phase 3: Workspace + Editor Debt + +Items deferred from v0.21.x that are low-hanging fruit. + +- [ ] `.gitignore` respect in workspace indexing (`workspace/indexer.go` — skip paths matching patterns from `.gitignore` in workspace root) +- [ ] Workspace settings UI: git config section in channel/project settings (remote URL, branch, auto-commit toggle) +- [ ] Pane state persistence per-user/per-project (save pane layout to `user_settings` JSONB, restore on surface load) +- [ ] Editor drag-drop file reorder (workspace file tree — reorder via DnD, persist order in workspace metadata) + +**Explicitly deferred (not v0.27.0):** +- User settings git credentials management UI — needs vault-encrypted git credential store (v0.28.0 candidate) +- Integration tests: clone/commit/push/pull — needs git binary in CI container +- Article-specific AI tools — needs article surface rebuild +- Mobile hamburger nav — needs responsive audit pass + +**Deliverables:** +- [ ] `.gitignore` filter in `workspace/indexer.go` +- [ ] Git config section in workspace/project settings UI +- [ ] Pane layout persistence in `user_settings` +- [ ] Editor file tree DnD reorder + + +### ROADMAP Cleanup + +Update the ROADMAP itself: +- [ ] Cross off tool grant enforcement (3 locations) +- [ ] Move "Deferred to v0.27.0" items under their proper v0.27.0 section +- [ ] Remove duplicate entries (extension surface routes listed 4× across the file) +- [ ] Add v0.27.0 section with phase structure +- [ ] Update dependency graph + +--- + +## v0.27.1 — Tasks Foundation: Service Channels + Scheduler + +The core primitive: a channel with no human participant, driven by +a scheduler. + +### Service Channels + +`type: 'service'` — a new channel type. Service channels: +- Have zero human participants (only persona participants) +- Cannot be joined by users (read-only view for authorized users) +- Are created by the scheduler or the `task_create` tool +- Inherit the workflow engine's stage model (optional — simple tasks + skip stages entirely) +- Appear in a "Tasks" sidebar section (collapsed by default) + +```sql +-- No new migration needed — channel type CHECK already allows extension. +-- Add 'service' to the channel type CHECK constraint: +ALTER TABLE channels DROP CONSTRAINT IF EXISTS channels_type_check; +ALTER TABLE channels ADD CONSTRAINT channels_type_check + CHECK (type IN ('direct','dm','group','channel','workflow','service')); +``` + +### Task Definitions + +A task is a lightweight scheduled job that creates a service channel +and runs a completion (or instantiates a workflow). + +**`tasks` table:** +```sql +CREATE TABLE tasks ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + team_id TEXT REFERENCES teams(id) ON DELETE SET NULL, + name TEXT NOT NULL, + description TEXT, + scope TEXT NOT NULL DEFAULT 'personal' + CHECK (scope IN ('personal', 'team', 'global')), + + -- What to run + task_type TEXT NOT NULL DEFAULT 'prompt' + CHECK (task_type IN ('prompt', 'workflow')), + persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL, + model_id TEXT, -- explicit model override (nullable) + system_prompt TEXT, -- additional system prompt (prepended) + user_prompt TEXT, -- the message to send + workflow_id TEXT REFERENCES workflows(id) ON DELETE SET NULL, + tool_grants JSONB, -- explicit tool allowlist (nullable = inherit all) + + -- Schedule + schedule TEXT NOT NULL, -- cron expression (5-field) + timezone TEXT NOT NULL DEFAULT 'UTC', + is_active BOOLEAN NOT NULL DEFAULT true, + + -- Execution policy + max_tokens INTEGER NOT NULL DEFAULT 4096, + max_tool_calls INTEGER NOT NULL DEFAULT 10, + max_wall_clock INTEGER NOT NULL DEFAULT 300, -- seconds + output_mode TEXT NOT NULL DEFAULT 'channel' + CHECK (output_mode IN ('channel', 'note', 'webhook')), + output_channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL, + webhook_url TEXT, + + -- Provider routing + provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL, + + -- Notifications + notify_on_complete BOOLEAN NOT NULL DEFAULT false, + notify_on_failure BOOLEAN NOT NULL DEFAULT true, + + -- Bookkeeping + last_run_at TIMESTAMPTZ, + next_run_at TIMESTAMPTZ, + run_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_tasks_next_run ON tasks (next_run_at) WHERE is_active = true; +CREATE INDEX idx_tasks_owner ON tasks (owner_id); +``` + +**Two task types:** + +1. **Prompt task** (`task_type = 'prompt'`): Create a service channel, + send `user_prompt` with `system_prompt` prepended, collect response. + Simple, single-turn. Good for: news digest, stock screener, daily + standup prep, report generation. + +2. **Workflow task** (`task_type = 'workflow'`): Instantiate a workflow + in a service channel. Multi-stage, tool-using, potentially long-running. + Good for: data pipeline, automated review, research agent. + +### Scheduler + +Background goroutine (same pattern as compaction scanner, staleness sweep): + +```go +type TaskScheduler struct { + stores store.Stores + interval time.Duration // poll interval: 30s + stop chan struct{} +} + +func (s *TaskScheduler) Run() { + ticker := time.NewTicker(s.interval) + for { + select { + case <-ticker.C: + s.poll() + case <-s.stop: + return + } + } +} + +func (s *TaskScheduler) poll() { + // SELECT * FROM tasks WHERE is_active AND next_run_at <= now() + // ORDER BY next_run_at LIMIT 10 + due, _ := s.stores.Tasks.ListDue(ctx) + for _, task := range due { + go s.execute(task) + } +} +``` + +Cron parsing: use `github.com/robfig/cron/v3` for 5-field expressions. +Compute `next_run_at` after each execution. + +### Provider Resolution for Tasks + +Task execution needs a provider. Resolution order: + +1. Explicit `provider_config_id` on task (user chose a specific provider) +2. Personal BYOK provider (if `scope = 'personal'` and user has BYOK keys) +3. Team provider (if `scope = 'team'`) +4. Global provider (fallback) +5. Routing policy (if task's model matches a routing policy) + +This is the same resolution chain as completions, reusing the existing +`resolveProvider()` path in `completion.go`. + +### Migration + +- 026_tasks.sql: `tasks` table, service channel type extension + +**Deliverables:** +- [ ] `tasks` table + store interface + PG/SQLite implementations +- [ ] `type: 'service'` channel type +- [ ] `TaskScheduler` background goroutine +- [ ] Cron expression parsing + `next_run_at` computation +- [ ] Task execution path (create service channel, run completion) +- [ ] Provider resolution for task context +- [ ] Tasks sidebar section (read-only view of service channels) + + +--- + +## v0.27.2 — Task Execution: Budgets + Admin Controls + +### Execution Budgets + +Three limits enforced in the task execution path: + +1. **`max_tokens`** — total output tokens. Tracked via existing + `usage_log` infrastructure. Execution halts mid-stream if exceeded. +2. **`max_tool_calls`** — total tool invocations per run. Counter + incremented in `streamWithToolLoop`. Execution halts on breach. +3. **`max_wall_clock`** — seconds. `context.WithTimeout` on the + execution goroutine. Hard kill on timeout. + +Budget breach → task marked as `budget_exceeded` in run history, +notification to owner. + +### Task Run History + +**`task_runs` table:** +```sql +CREATE TABLE task_runs ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'running' + CHECK (status IN ('running','completed','failed', + 'budget_exceeded','cancelled')), + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + tokens_used INTEGER DEFAULT 0, + tool_calls INTEGER DEFAULT 0, + wall_clock INTEGER DEFAULT 0, -- seconds + error TEXT +); +``` + +### Admin Controls + +**Global config keys:** +- `tasks.enabled` — master kill switch (default: true) +- `tasks.allow_personal` — whether non-admin users can create personal + tasks (default: false). Same pattern as `personas.allow_personal`. +- `tasks.max_concurrent` — max simultaneous task executions (default: 5) +- `tasks.default_budget.max_tokens` — default ceiling (overridable per task) +- `tasks.default_budget.max_tool_calls` — default ceiling +- `tasks.default_budget.max_wall_clock` — default ceiling (seconds) + +**Admin panel — Tasks section:** +- List all tasks (filterable by owner, team, scope, status) +- View task run history with budget usage +- Pause/resume individual tasks +- Kill running executions +- Edit default budgets +- Toggle `tasks.allow_personal` + +**Permission integration:** +- New permission: `tasks.create` — required to create tasks +- New permission: `tasks.admin` — required to manage others' tasks +- `Everyone` group gets `tasks.create` if `tasks.allow_personal` is true + +**Deliverables:** +- [ ] Execution budget enforcement (tokens, tool calls, wall clock) +- [ ] `task_runs` table + store +- [ ] Budget breach notification to task owner +- [ ] Global config keys for task admin controls +- [ ] Admin panel Tasks section (CRUD, history, kill switch) +- [ ] `tasks.create` and `tasks.admin` permissions +- [ ] `tasks.allow_personal` toggle (mirrors `personas.allow_personal`) + + +--- + +## v0.27.3 — Task Chaining: Webhooks + Workflow-to-Workflow + +### Completion Webhooks + +When a task (or workflow) completes, optionally POST to an external URL: + +```go +type WebhookPayload struct { + TaskID string `json:"task_id"` + TaskName string `json:"task_name"` + Status string `json:"status"` + ChannelID string `json:"channel_id"` + CompletedAt time.Time `json:"completed_at"` + Output string `json:"output"` // last assistant message + StageData any `json:"stage_data"` // workflow stage data (if applicable) +} +``` + +- Webhook URL on task or workflow (`webhook_url` column) +- Retry: 3 attempts with exponential backoff (1s, 5s, 25s) +- Timeout: 10s per attempt +- HMAC signature header (`X-Switchboard-Signature`) using a per-task secret + +### `task_create` Tool + +AI-invocable tool that spawns sub-tasks: + +```json +{ + "name": "task_create", + "description": "Create a new scheduled or one-shot task", + "parameters": { + "name": "string", + "prompt": "string", + "schedule": "string (cron or 'once')", + "persona": "string (handle, optional)", + "max_tokens": "integer (optional)" + } +} +``` + +- `RequireWorkflow` or `RequireTeam` predicate (not available in + personal chats — prevents runaway task creation) +- One-shot tasks: `schedule = 'once'`, `next_run_at = now()` +- Created tasks inherit the parent's scope (team/global) +- Depth limit: tasks cannot create tasks (no recursive spawning) + +### Workflow-to-Workflow Chaining + +Wires the `on_complete` column (v0.26.1) into the task system: + +- On workflow completion with `on_complete` set: create a one-shot task + that instantiates the target workflow with mapped data +- Data mapping: `data_map` keys in `on_complete` JSONB map source + `stage_data` fields to target workflow's initial `stage_data` + +**Deliverables:** +- [ ] Webhook delivery with retry + HMAC signature +- [ ] `task_create` tool with depth limit +- [ ] `on_complete` chaining via task system +- [ ] Webhook secret generation per task/workflow + + +--- + +## v0.27.4 — Personal Tasks: BYOK Scheduling + User Task UI + +The user-facing task experience. Users create personal tasks that run +against their BYOK providers. + +### Use Cases + +- **Morning news digest:** Daily at 6am, prompt "Summarize top tech news", + output to a personal service channel. Persona: "News Analyst" with + `web_search` tool grant. +- **Stock screener:** Weekdays at market open, prompt "Check my watchlist + for unusual pre-market activity", BYOK OpenAI key for cost control. +- **Weekly project summary:** Friday at 5pm, prompt "Summarize this + week's activity across my projects", uses `conversation_search` + + `workspace_search` tools. +- **Daily standup prep:** Weekday mornings, reviews yesterday's notes + and generates standup talking points. + +### Settings Surface — Tasks Section + +New section in user settings (same pattern as BYOK, Personas): + +- Task list: name, schedule (human-readable), last run status, next run +- Create task: name, persona picker, model picker, prompt editor, + schedule builder (preset crons + custom), budget overrides +- Task detail: run history, output channel link, edit, pause/delete +- BYOK indicator: shows which provider the task will use + +### Schedule Builder + +Preset schedules + custom cron for power users: + +| Preset | Cron | +|--------|------| +| Every morning (6am) | `0 6 * * *` | +| Weekday mornings (8am) | `0 8 * * 1-5` | +| Every hour | `0 * * * *` | +| Weekly (Monday 9am) | `0 9 * * 1` | +| Monthly (1st at midnight) | `0 0 1 * *` | +| Custom... | user-entered 5-field cron | + +Timezone selector defaults to browser timezone. + +### BYOK Task Routing + +Personal tasks prefer the user's BYOK provider: + +1. If task has explicit `provider_config_id` → use it +2. If user has a BYOK provider config for the task's model → use BYOK +3. Fall through to team/global provider (if allowed by admin) + +Admin can restrict personal tasks to BYOK-only via +`tasks.personal_require_byok` config key (default: false). When true, +personal tasks without a BYOK provider fail with a clear error instead +of falling through to org providers. + +### Output Modes + +Three output destinations: + +1. **Channel** (default): Output goes to a persistent service channel. + User views it like a read-only chat. Channel accumulates run outputs + over time (scrollable history). +2. **Note**: Output saved as a channel-scoped note (good for structured + data, reports). Note title includes timestamp. +3. **Webhook**: Output POSTed to external URL (for integration with + other tools — Slack, email, etc.). + +### Task Sidebar Section + +New sidebar section (below Channels, above Chats): + +``` +▾ Tasks (collapsible) + ◷ Morning News Digest ✓ 6:02am + ◷ Stock Screener ✓ 9:30am + ◷ Weekly Summary ⏳ Fri 5pm + + New task +``` + +Click opens the task's output channel. Status indicators: ✓ (last run +succeeded), ✗ (failed), ⏳ (next run time), ▶ (running now). + +**Deliverables:** +- [ ] Settings → Tasks section (CRUD, schedule builder, budget config) +- [ ] BYOK provider routing for personal tasks +- [ ] `tasks.personal_require_byok` config key +- [ ] Output modes: channel, note, webhook +- [ ] Tasks sidebar section with status indicators +- [ ] "Run Now" button for manual trigger +- [ ] Task output channel read-only view + + +--- + +## v0.28.0 — Platform Polish (TBD Pull-Forward) + +Candidates pulled from the TBD section, prioritized by value and +dependency readiness. + +### Tier 1 — High value, dependencies met + +**Virtual scroll for long conversations** +Conversations with 500+ messages bog down the DOM. Virtual scroll renders +only visible messages + buffer zone. Prerequisite for heavy task output +channels. + +**KB auto-injection (context-aware)** +Top-K chunk prepend to system prompt, context budget aware, per-channel +toggle. Useful for tasks that need domain knowledge without explicit +`kb_search` tool calls. Latency budgeting required (embedding lookup +adds ~200ms). + +**Helm chart** +Raw k8s manifests with `${VAR}` substitution → proper Helm chart. +`values.yaml` for replicas, image tags, ingress, storage, secrets, +feature flags. Subchart for dev/test Postgres. Target: `helm install +switchboard ./chart`. Now makes sense because the feature set is +stabilizing post-tasks. + +**Per-provider model preferences** +`user_model_settings` unique key is `(user_id, model_id)` — same model +from different providers shares one visibility toggle. Needs +`provider_config_id` dimension in DB constraint, store, API, and +frontend `hiddenModels` keying. Natural fit now that BYOK tasks need +per-provider model selection. + +### Tier 2 — Medium value, some design work needed + +**Memory compaction** +Summarize old memories to save context tokens. Confidence decay: reduce +confidence over time, prune low-confidence entries. Natural extension of +the memory system (v0.18.0). + +**`capability_match` routing policy** +"Cheapest model with tool_calling" — a new routing policy type. Useful +for tasks that need tool use but don't need the strongest model. + +**New provider types via config file** +OpenAI-compatible endpoints registrable via YAML config (no Go code). +Enables users to point at local LLMs (Ollama, vLLM, llama.cpp) without +a provider code change. + +### Tier 3 — Future (v0.29+) + +- Desktop app (Tauri) — large scope, independent track +- Full PWA with offline — needs service worker rewrite +- Plugin/extension marketplace — needs extension surface routes first (v0.27.0) +- Starlark/sidecar extension tiers — needs extension surface routes first +- Git credentials vault store — needs vault extension design +- Cross-persona memory sharing — needs memory system redesign + + +--- + +## Design Decision: Task Permission Model + +Tasks introduce a new resource type that intersects with multiple +existing permission boundaries: + +| Scope | Who can create | Provider used | Visibility | +|-------|---------------|---------------|------------| +| Personal | User (if `tasks.allow_personal`) | User's BYOK or team fallback | Owner only | +| Team | User with `tasks.create` + team membership | Team provider | Team members | +| Global | Admin | Global provider | All users (read) | + +Personal tasks are the BYOK sweet spot. The admin toggle +(`tasks.allow_personal`) mirrors the existing `personas.allow_personal` +pattern — a single boolean in global config, surfaced in the admin +settings panel. + +**Why not just a permission?** The `tasks.create` permission already +gates the ability. `tasks.allow_personal` is a _policy_ control — +admins might allow task creation for team tasks but prohibit personal +tasks to prevent uncontrolled BYOK spending. Two orthogonal knobs. + + +--- + +## Design Decision: Task vs. Workflow Relationship + +Tasks and workflows are related but distinct: + +| | Task | Workflow | +|---|------|---------| +| Trigger | Cron schedule or `task_create` tool | Human click or task trigger | +| Participants | Zero humans (service channel) | Humans + AI | +| Complexity | Single prompt or workflow instantiation | Multi-stage, assignment queue | +| Output | Channel, note, or webhook | Channel (interactive) | +| Lifecycle | Recurring (cron) or one-shot | Single instance | + +A **prompt task** is simpler than a workflow — it's a scheduled +completion. A **workflow task** is a task that instantiates a workflow. +This keeps the task system lean (scheduling + budgets + output routing) +and the workflow engine rich (stages + assignment + personas). + +The `task_type` column makes this explicit. Prompt tasks don't need +workflows at all — they're a direct scheduler → completion path. +Workflow tasks reuse the full workflow engine. + + +--- + +## Resolved Decisions + +1. **Task output retention.** Use channel-level retention from v0.26.0. + No per-task retention policy needed — the channel's `retention` JSONB + handles archive/delete semantics. Notes created by task output survive + channel removal (notes are channel-scoped but not cascade-deleted). + Storage quotas per user are a future concern (v0.29+ candidate) — not + blocking for initial task support. + +2. **Concurrent task execution.** Skip with a warning log. If + `task_runs` has a row with `status = 'running'` for the task, the + scheduler skips that tick. Log: `[scheduler] Skipping task %s — previous + run still active`. No queue — if a task consistently overruns its + schedule, the user needs to adjust the cron interval or budget. + +3. **Task templates.** Ship 3–5 optional starter templates in v0.27.4. + Presented as suggestions during task creation ("Start from a + template..." option alongside blank task). Not auto-created — user + must explicitly choose one. Templates are just pre-filled form values, + not persistent DB records. + +4. **Task notifications.** Opt-in per task. Two booleans on the `tasks` + table: `notify_on_complete` (default false), `notify_on_failure` + (default true). Uses existing notification infrastructure (v0.20.0 + bell + WebSocket push). diff --git a/docs/EXTENSION-SURFACES.md b/docs/EXTENSION-SURFACES.md new file mode 100644 index 0000000..e7b1a48 --- /dev/null +++ b/docs/EXTENSION-SURFACES.md @@ -0,0 +1,453 @@ +# Extension Surfaces — Authoring Guide + +**Version:** v0.27.0+ +**Audience:** Developers building custom surfaces for Chat Switchboard +**Prerequisite:** Admin access to install surfaces + +--- + +## What Is an Extension Surface? + +An extension surface is a custom full-page UI that runs inside the +Chat Switchboard shell. It gets its own URL (`/s/your-surface`), a +link in the sidebar, and full access to the platform's authenticated +API, theme system, and UI components. + +Examples of what you can build: + +- A monitoring dashboard that polls external APIs +- A custom form builder for workflow intake +- A kanban board backed by channel data +- A cost tracking view using usage data +- A project timeline visualization + +Extension surfaces are installed as `.surface` archives via the admin +panel. No code changes to the platform are required. + +--- + +## Quick Start + +### 1. Create the Archive + +A `.surface` file is a zip archive with this structure: + +``` +my-dashboard/ +├── manifest.json ← required: surface metadata +├── js/ +│ └── main.js ← required: entry point script +└── css/ + └── main.css ← optional: styles +``` + +The top-level directory name must match the `id` in `manifest.json`. + +### 2. Write the Manifest + +```json +{ + "id": "my-dashboard", + "title": "My Dashboard", + "route": "/s/my-dashboard", + "auth": "authenticated", + "layout": "single", + "version": "1.0.0", + "description": "A custom dashboard surface." +} +``` + +| Field | Required | Description | +|---------------|----------|-------------| +| `id` | yes | Unique identifier. Lowercase, alphanumeric + hyphens. Must match the archive's top-level directory name. | +| `title` | yes | Human-readable name shown in the sidebar nav. | +| `route` | no | URL path. Defaults to `/s/{id}` if omitted. Must start with `/s/`. | +| `auth` | no | Auth requirement. Only `"authenticated"` is supported currently. Default: `"authenticated"`. | +| `layout` | no | Layout preset. Only `"single"` is supported currently. Default: `"single"`. | +| `version` | no | Semver string for your own tracking. Not enforced. | +| `description` | no | Shown in the admin surfaces panel. | + +### 3. Write the Entry Point + +`js/main.js` runs after all platform scripts have loaded. Your code +mounts into the `#extension-mount` container: + +```js +(function() { + 'use strict'; + + var mount = document.getElementById('extension-mount'); + if (!mount) return; + + var manifest = window.__MANIFEST__ || {}; + var user = window.__USER__ || {}; + + mount.innerHTML = '

Hello, ' + esc(user.display_name || user.username) + '!

'; + + function esc(s) { + var el = document.createElement('span'); + el.textContent = s; + return el.innerHTML; + } +})(); +``` + +### 4. Package and Install + +```sh +zip -r my-dashboard.surface my-dashboard/ +``` + +Then in the admin panel: **Admin → Surfaces → Upload** and select the +`.surface` file. The surface is immediately available — no restart +required. A link appears in the sidebar. + +--- + +## Platform Globals + +Your `main.js` runs in the full platform context. These globals are +available when your script executes: + +### Window Injections + +| Global | Type | Description | +|--------|------|-------------| +| `window.__MANIFEST__` | Object | Your surface's manifest with route, layout, etc. Keys are **lowercase** (JSON serialization from Go). Access as `manifest.id`, `manifest.route`, `manifest.title`. | +| `window.__USER__` | Object | Authenticated user: `{ username, display_name, role, id, email }`. | +| `window.__BASE__` | String | URL base path (e.g. `"/dev"` or `""`). Prepend to all internal links. | +| `window.__VERSION__` | String | Platform version string. | +| `window.__SURFACE__` | String | Your surface ID. | + +### API Module + +`API` provides authenticated fetch wrappers. Auth headers and token +refresh are handled automatically. + +```js +// GET request — returns parsed JSON +var data = await API._get('/api/v1/channels'); + +// POST request — body is JSON-serialized automatically +var result = await API._post('/api/v1/channels', { + name: 'my-channel', + type: 'channel' +}); + +// PUT request +await API._put('/api/v1/channels/' + id, { name: 'renamed' }); +``` + +All methods return a Promise that resolves to parsed JSON. On 401, +tokens are refreshed automatically and the request is retried. + +**Important:** The methods are `_get`, `_post`, `_put` — prefixed with +underscore. There is no `API.get()`. + +### UI Module + +`UI` provides platform UI primitives: + +```js +// Toast notification — types: 'success', 'error', 'info', 'warning' +UI.toast('Operation completed', 'success'); +UI.toast('Something went wrong', 'error'); + +// Navigate to settings +UI.openSettings('general'); +``` + +### Theme Module + +`Theme` manages dark/light mode: + +```js +// Current resolved theme: 'dark' or 'light' +var mode = Theme.resolved(); + +// Check via DOM attribute +var isDark = document.documentElement.getAttribute('data-theme') === 'dark'; +``` + +Your CSS should use CSS custom properties (see below) rather than +querying Theme directly. Properties update automatically on theme change. + +### Events Module + +`Events` is the platform event bus (WebSocket-backed): + +```js +// Subscribe to events +Events.on('chat.message.created', function(data) { + console.log('New message:', data); +}); + +// Emit local-only events +Events.emit('my-surface.updated', { key: 'value' }); +``` + +### ChatPane Component + +If your surface needs an embedded chat, you can mount a ChatPane: + +```js +var container = document.createElement('div'); +container.style.height = '400px'; +mount.appendChild(container); + +ChatPane.create(container, { role: 'assist' }); +``` + +--- + +## CSS Custom Properties + +Your CSS should use the platform's CSS custom properties for consistent +theming. These update automatically when the user switches themes. + +### Colors + +| Property | Dark Default | Light Default | Usage | +|----------|-------------|---------------|-------| +| `--bg` | `#0e0e10` | `#f7f7fa` | Page background | +| `--bg-surface` | `#18181b` | `#ffffff` | Card/panel background | +| `--bg-raised` | `#222227` | `#eff0f3` | Elevated element background | +| `--bg-hover` | `#2a2a30` | `#e5e6eb` | Hover state background | +| `--border` | `#2e2e35` | `#d5d6dc` | Default borders | +| `--border-light` | `#3a3a42` | `#c2c3cb` | Subtle borders | +| `--text` | `#e8e8ed` | `#1a1a2e` | Primary text | +| `--text-2` | `#9898a8` | — | Secondary text | +| `--text-3` | `#6b6b7b` | — | Tertiary/muted text | +| `--accent` | `#6c9fff` | — | Accent color (links, highlights) | +| `--accent-hover` | `#84b0ff` | — | Accent hover state | +| `--accent-dim` | `rgba(108,159,255,0.12)` | — | Accent tinted background | +| `--danger` | `#ef4444` | — | Error/destructive actions | +| `--success` | `#22c55e` | — | Success indicators | +| `--warning` | `#eab308` | — | Warning indicators | + +### Layout + +| Property | Default | Usage | +|----------|---------|-------| +| `--radius` | `8px` | Default border radius | +| `--radius-lg` | `12px` | Large border radius | +| `--font` | `'DM Sans', ...` | Primary font stack | +| `--mono` | `'JetBrains Mono', ...` | Monospace font stack | +| `--transition` | `180ms ease` | Default transition timing | + +### Example + +```css +.my-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + color: var(--text); +} + +.my-card-subtitle { + color: var(--text-2); + font-size: 13px; +} + +.my-card:hover { + background: var(--bg-hover); +} +``` + +**Do not use** `--bg-secondary`, `--text-secondary`, or +`--text-tertiary` — these do not exist. Use `--bg-surface`, `--text-2`, +and `--text-3` respectively. + +--- + +## DOM Structure + +When your surface loads, the page DOM looks like this: + +``` + +
+
+
+
...
← platform nav +
+ ← YOUR CONTENT GOES HERE +
+
+
+
+ + + + + ... platform scripts ... + ← YOUR SCRIPT + +``` + +The `#extension-mount` div uses `flex: 1` and `overflow: auto`, so it +fills all available vertical space. You can set its content to anything. + +--- + +## Admin API Reference + +Surfaces are managed via the admin API. All endpoints require admin +authentication. + +### Install a Surface + +``` +POST /api/v1/admin/surfaces/install +Content-Type: multipart/form-data + +file: <.surface or .zip file> +``` + +The archive must contain a `manifest.json` with `id` and `title`. +Static assets (`js/`, `css/`, `assets/`) are extracted to the server's +storage directory. The surface is registered in the database and +enabled immediately. + +**Response:** +```json +{ + "id": "my-dashboard", + "title": "My Dashboard", + "source": "extension", + "enabled": true +} +``` + +### List All Surfaces + +``` +GET /api/v1/admin/surfaces +``` + +Returns all surfaces (core + extension) with their enabled state. + +### Enable / Disable + +``` +PUT /api/v1/admin/surfaces/:id/enable +PUT /api/v1/admin/surfaces/:id/disable +``` + +Disabled surfaces redirect to the chat surface. The sidebar link is +hidden. Core surfaces (`chat`, `admin`) cannot be disabled. + +### Uninstall + +``` +DELETE /api/v1/admin/surfaces/:id +``` + +Removes the surface registration and cleans up extracted static assets. +Core surfaces cannot be deleted. + +### List Enabled (Non-Admin) + +``` +GET /api/v1/surfaces +``` + +Returns enabled surfaces with minimal info (id, title, route). Used by +the sidebar to render nav links. Available to all authenticated users. + +--- + +## Platform CSS Classes + +Your extension can use these CSS classes from the platform's +`primitives.css` without importing anything: + +### Buttons + +```html + + + + + +``` + +### Badges + +```html +Default +Success +Error +Warning +``` + +### Toast (via JS) + +```js +UI.toast('Message here', 'success'); // green +UI.toast('Message here', 'error'); // red +UI.toast('Message here', 'info'); // blue +UI.toast('Message here', 'warning'); // yellow +``` + +--- + +## Tips + +**Always wrap in an IIFE.** Your script shares the global scope with +platform scripts. Use `(function() { ... })();` to avoid collisions. + +**Use `esc()` for user-generated content.** The platform does not +provide a global escaping function. Define your own: + +```js +function esc(s) { + var el = document.createElement('span'); + el.textContent = s; + return el.innerHTML; +} +``` + +**Prepend `__BASE__` to internal links.** The platform may be deployed +under a path prefix (e.g. `/dev`): + +```js +var base = window.__BASE__ || ''; +link.href = base + '/settings/general'; +``` + +**Check for globals before using them.** If your surface might be +previewed outside the platform: + +```js +if (typeof API !== 'undefined' && API._get) { + // safe to make API calls +} +if (typeof UI !== 'undefined' && UI.toast) { + UI.toast('Works!', 'success'); +} +``` + +**Size limit:** Archive upload is capped at 50 MB. Only `js/`, `css/`, +and `assets/` directories are extracted from the archive. + +**Cache busting:** Static assets are served with `?v={platform_version}` +query parameter. When the platform is upgraded, caches are invalidated +automatically. + +--- + +## Full Example + +See the included `hello-dashboard.surface` archive for a complete +working example that demonstrates: + +- Manifest structure +- Mounting into `#extension-mount` +- Reading `__MANIFEST__` and `__USER__` +- Using `UI.toast()` for notifications +- Using `API._get()` for authenticated requests +- Using CSS custom properties for theming +- Proper `esc()` function for XSS safety diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 4ec5466..836258a 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -298,137 +298,43 @@ tools. The editor extension defines `read_file`, `write_file`, --- -## 6. Surfaces (Modes) +## 6. Extension Surfaces -*Implemented in v0.21.3.* +*Shipped in v0.27.0.* -A "mode" is an extension that registers a **surface** — a UI region that -replaces or augments the default chat area. The surface system preserves -DOM state across mode switches (critical for CM6 editor instances). +Extension surfaces are full-page custom UIs installed as `.surface` +archives and served at `/s/:slug`. They run inside the platform shell +with access to the authenticated API, theme system, and UI components. -### 6.1 Surface Regions +See **[EXTENSION-SURFACES.md](EXTENSION-SURFACES.md)** for the complete +authoring guide including manifest format, platform API reference, CSS +custom properties, and a worked example. -The following `data-surface-region` attributes are present in `index.html`: +### Summary -``` -┌─────────────────────────────────────────────┐ -│ sidebar-top │ surface-header │ -│ │ (model-bar) │ -│ mode-selector │ │ -│ (auto-shown │ surface-main │ -│ when ≥2 │ (chatMessages) │ -│ surfaces) │ │ -│ │ │ -│ sidebar-content │ surface-footer │ -│ (search+chats) │ (input-area) │ -│ │ │ -│ sidebar-bottom │ │ -└─────────────────────────────────────────────┘ -``` +- **Archive format:** zip with `manifest.json` + `js/main.js` + optional `css/main.css` +- **Install:** Admin panel → Surfaces → Upload (no restart required) +- **Route:** `/s/{id}` — rendered via Go templates, DB lookup at request time +- **Entry point:** `js/main.js` mounts into `#extension-mount` +- **Platform access:** `API._get()`, `UI.toast()`, `Theme.resolved()`, `Events.on()`, `ChatPane.create()` +- **Theming:** CSS custom properties from `variables.css` (`--bg`, `--bg-surface`, `--text`, `--text-2`, `--accent`, etc.) +- **Admin API:** install, enable/disable, uninstall via `/api/v1/admin/surfaces/*` -### 6.2 Surface Registration +### Relationship to Other Extension Types -**Manifest-based** (planned — extension loader integration): +Extension surfaces are the **most capable** extension type. They create +entirely new pages. Other extension types are lighter-weight: -```json -{ - "surfaces": [ - { - "id": "editor", - "label": "Editor", - "icon": "code", - "regions": ["surface-main", "surface-footer", "sidebar-content"], - "default": false - } - ] -} -``` +| Type | Creates a page? | Uses | +|------|----------------|------| +| **Surface** (`/s/:slug`) | Yes — full page | Custom dashboards, tools, visualizations | +| **Block renderer** (Hook 3) | No — transforms message content | Mermaid diagrams, KaTeX math, CSV tables | +| **Tool** (Hook 5) | No — LLM-invocable function | Calculator, web search, file operations | +| **Post-render** (Hook 3) | No — enhances rendered messages | Syntax highlighting, link previews | -**Imperative** (implemented): - -```js -Extensions.register({ - id: 'editor-mode', - init(ctx) { - ctx.surfaces.register('editor', { - label: 'Editor', - icon: 'code', - regions: ['surface-main', 'surface-footer', 'sidebar-content'], - activate() { - ctx.ui.replace('surface-main', this.renderEditor()); - ctx.ui.replace('surface-footer', this.renderEditorInput()); - ctx.ui.replace('sidebar-content', this.renderFileTree()); - }, - deactivate() { - ctx.ui.restore('surface-main'); - ctx.ui.restore('surface-footer'); - ctx.ui.restore('sidebar-content'); - } - }); - } -}); -``` - -**Region management API** (on `ctx.ui`): - -- `ctx.ui.replace(regionId, element)` — saves current children to a - `DocumentFragment` (detached, not destroyed), inserts new element. -- `ctx.ui.restore(regionId)` — re-attaches saved children. - -This is critical for CM6 — editor instances survive mode switches because -their DOM nodes are detached (preserving internal state) rather than removed. - -**Surface management API** (on `ctx.surfaces`): - -- `ctx.surfaces.register(id, opts)` — register a new surface -- `ctx.surfaces.unregister(id)` — unregister (switches to chat if active) -- `ctx.surfaces.activate(id)` — switch to a surface -- `ctx.surfaces.getCurrent()` — returns active surface id - -### 6.3 Mode Selector - -When extensions register surfaces, a mode selector appears in the sidebar -(`#modeSelectorWrap`) below the brand/new-chat area. Clicking a mode calls -`activate()` on that surface and `deactivate()` on the current one. - -Events fired: -```js -Events.on('surface.activated', ({ surface, previous }) => { ... }); -Events.on('surface.deactivated', ({ surface }) => { ... }); -Events.on('surface.registered', ({ surface }) => { ... }); -Events.on('surface.unregistered', ({ surface }) => { ... }); -``` - -### 6.4 Core Surface - -Chat mode is the default surface. It's not special architecturally — it's -the surface that's active when no extension surface is selected. Pragmatically -it stays in core because everything depends on it. - -### 6.5 Planned Modes - -**Editor Mode** (extension) -- File tree in sidebar, code editor in main, AI chat in split/overlay. -- Tools: `read_file`, `write_file`, `search_replace`, `git_status`, - `git_commit` — browser tools backed by a git provider API. -- This is ai-editor rebuilt properly: tool calls are server-side, logged, - token-counted, auditable. - -**Article Mode** (extension) -- Outline in sidebar, rich text editor in main, AI assistant in panel. -- Tools: `fetch_url`, `summarize_section`, `check_citation`. -- The conversation is hidden; only the document matters. - -**Cluster Manager Mode** (2-3 cooperating extensions) -- `k8s-manager`: kubectl tools, pod/deployment views. -- `ceph-manager`: ceph status, OSD management. -- `node-manager`: SSH commands, system metrics. -- These talk to each other via EventBus. When `k8s-manager` detects a - node is NotReady, it publishes `cluster.node.unhealthy`. `ceph-manager` - subscribes and checks OSD placement. -- The LLM sees ALL tools from ALL active cluster extensions. "Drain node-3, - ensure Ceph rebalances, then cordon it" → `kubectl_drain`, `ceph_osd_out`, - `kubectl_cordon` — each routed to the appropriate extension. +A single extension can combine multiple types — e.g. a surface for +configuration plus a tool for LLM interaction plus a renderer for +custom output formatting. --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 79aa408..0f1f93b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -4,6 +4,8 @@ - [ARCHITECTURE.md](ARCHITECTURE.md) — Core services design, store layer, scope model - [EXTENSIONS.md](EXTENSIONS.md) — Extension system spec (Browser/Starlark/Sidecar tiers, manifests, browser tool bridge, surfaces/modes, model roles) +- [EXTENSION-SURFACES.md](EXTENSION-SURFACES.md) — Extension surface authoring guide + (manifest format, platform API, CSS properties, install workflow) - [CHANGELOG.md](../CHANGELOG.md) — Detailed release notes for all completed versions **Versioning (pre-1.0):** `0..` — hotfixes use quad: `0.x.y.z` @@ -152,9 +154,29 @@ v0.26.0 Workflow Engine ✅ human + AI collab, visitor intake, assignment queue) │ -v0.27.0 Tasks / Autonomous Agents +v0.27.0 Debt Clearance + (extension surface routes, + workflow polish, editor debt) + │ +v0.27.1 Tasks Foundation (service channels, scheduler, - unattended execution) + task definitions, cron) + │ +v0.27.2 Task Execution + (budgets, admin controls, + run history, kill switch) + │ +v0.27.3 Task Chaining + (webhooks, task_create tool, + workflow-to-workflow) + │ +v0.27.4 Personal Tasks + (BYOK scheduling, user task UI, + starter templates) + │ +v0.28.0 Platform Polish + (virtual scroll, KB auto-inject, + Helm chart, provider model prefs) ``` --- @@ -427,28 +449,28 @@ Organized by domain. Pull into a version when the surrounding work makes them a natural addition. **Surfaces + Editor** -- [ ] Extension loader: surfaces from manifest.json (was v0.21.3 — `/s/:slug` route deferred to v0.27.0) -- [ ] Editor drag-drop file reorder (was v0.21.5) +- [x] ~~Extension loader: surfaces from manifest.json~~ (v0.27.0 — `/s/:slug` route) +- [x] ~~Editor drag-drop file reorder~~ (v0.27.0 — workspace debt) - [ ] Article drag-to-reorder outline sections (was v0.21.6) - [ ] Article-specific AI tools: suggest_outline, expand_section, check_citations (was v0.21.6) - [x] ~~PDF export via pandoc~~ (shipped v0.22.4) - [ ] Mobile: mode selector collapses to hamburger/bottom nav (was v0.21.3) -- [ ] Pane state persistence per-user/per-project (pane positions saved, not yet persisted across sessions) +- [x] ~~Pane state persistence per-user/per-project~~ (v0.27.0 — workspace debt) **Workspace + Git** -- [ ] Workspace settings UI: git config section in channel/project settings (was v0.21.4) -- [ ] User settings: git credentials management UI (was v0.21.4) -- [ ] `.gitignore` respect in workspace indexing (was v0.21.4) +- [x] ~~Workspace settings UI: git config section in channel/project settings~~ (v0.27.0 — workspace debt) +- [ ] User settings: git credentials management UI (was v0.21.4 — needs vault extension, v0.28.0+) +- [x] ~~`.gitignore` respect in workspace indexing~~ (v0.27.0 — workspace debt) - [ ] Integration tests: clone, commit, push/pull cycle — requires git binary in CI (was v0.21.4) **Provider Health + Routing** - [x] ~~`RecordOutcome` for web_search and url_fetch~~ (shipped v0.22.4 — tool health tracking) - [x] ~~Rate limit tracking per provider config~~ (shipped v0.22.4 — `rate_limit_count` on `provider_health`) - [x] ~~Auto-disable: mark provider inactive when `down` for N consecutive health windows~~ (shipped v0.22.4) -- [ ] `capability_match` policy type ("cheapest model with tool_calling") +- [ ] `capability_match` policy type ("cheapest model with tool_calling") — v0.28.0 candidate - [ ] Latency-aware routing: track response time percentiles, prefer faster providers - [ ] Cost-aware routing with budget ceiling per request -- [ ] New provider types registrable via config file (OpenAI-compatible + custom schema) +- [ ] New provider types registrable via config file (OpenAI-compatible + custom schema) — v0.28.0 candidate - [ ] Provider profile editor: key-value config per provider type, preview of effective settings - [ ] Fallback chain visualizer: drag-to-reorder provider priority per model family @@ -456,7 +478,8 @@ makes them a natural addition. - [x] ~~Session cleanup job~~ (shipped v0.26.0 — background goroutine, `SESSION_EXPIRY_DAYS`) **Tool System** -- [x] ~~Persona tool grant enforcement~~ (shipped v0.25.0 — second-pass allowlist in completion handler) +- [x] ~~Persona tool grant enforcement~~ (shipped v0.25.0 — second-pass allowlist in `completion.go:928`) +- [x] ~~Workflow version snapshot includes tool grants~~ (shipped v0.26.0 — `workflows.go:263`) --- @@ -739,7 +762,7 @@ See [DESIGN-0.25.0.md](DESIGN-0.25.0.md) for full spec. - [x] Core surfaces (chat, admin, settings, editor, notes) on manifest pattern - [x] `window.__PAGE_DATA__` injection from declared `data_requires` - [x] `surface_registry` table — admin can enable/disable surfaces (Migration 022) -- [ ] `/s/:slug` route namespace for extension/dynamic surfaces — deferred +- [ ] `/s/:slug` route namespace for extension/dynamic surfaces — v0.27.0 **Component Extraction** ✅ - [x] FileTree: extracted from `editor-mode.js`, standalone component with Go template partial + JS hydration @@ -767,11 +790,11 @@ See [DESIGN-0.25.0.md](DESIGN-0.25.0.md) for full spec. **CSS Decomposition** ✅ - [x] Monolithic `styles.css` → 13 files: variables, layout, primitives, modals, chat, panels, surfaces, splash, pane-container, chat-pane, user-menu, tool-grants, admin-surfaces -**Persona Tool Grants UI** ✅ (partial — UI only, backend wire deferred) +**Persona Tool Grants UI** ✅ - [x] Persona create/edit UI: "Tools" section — multi-select checklist grouped by category - [x] `loadToolList()` fetches from `/api/v1/tools`, `loadToolGrants()` reads per-persona grants -- [ ] Completion handler applies `GetToolGrants()` as second-pass allowlist — deferred to v0.27.0 -- [ ] Version snapshot includes persona tool grants at snapshot time — deferred to v0.27.0 +- [x] Completion handler applies `GetToolGrants()` as second-pass allowlist (`completion.go:928`) +- [x] Version snapshot includes persona tool grants at snapshot time (`workflows.go:263`) **Bug Fixes (v0.25.3–v0.25.4)** - [x] Theme system mode (system preference → dark fallback) @@ -792,16 +815,19 @@ See [DESIGN-0.25.0.md](DESIGN-0.25.0.md) for full spec. **Shared Surface Routes** — partially shipped - [x] `/admin/:section`, `/settings/:section` — full-page surface routes -- [ ] `/s/editor/:wsId`, `/s/article/:wsId/:path` — deferred -- [ ] `/p/:id` — shared project view — deferred +- [ ] `/s/editor/:wsId`, `/s/article/:wsId/:path` — v0.27.0 (extension surface routes) +- [ ] `/p/:id` — shared project view — TBD **Shipped in v0.26.0:** - [x] Context-Aware Tool System (`ToolContext`, `Require` predicates, `AvailableFor()`) - [x] `ExecutionContext` extension (`WorkflowID`, `TeamID` fields) -**Deferred to v0.27.0:** -- [ ] Tool grant enforcement in completion handler -- [ ] Extension surface route namespace (`/s/:slug`) +**Shipped (verified in code audit):** +- [x] Tool grant enforcement in completion handler (`completion.go:928`) +- [x] Workflow version snapshot includes tool grants (`workflows.go:263`) + +**Moved to v0.27.0:** +- [x] ~~Extension surface route namespace (`/s/:slug`)~~ → v0.27.0 Phase 1 **Migration:** - [x] 022_surfaces.sql: `surface_registry` table (UUID PK, unique surface_id, is_enabled, is_core) @@ -836,7 +862,7 @@ release notes. - [x] Route registration test (`TestRouteRegistration`) - [x] Workflow CRUD test (`TestWorkflowCRUD`, `TestWorkflowValidation`) -**Deferred to v0.27.0:** +**Moved to v0.27.0 Phase 2 (Workflow Polish):** - [ ] Team-scoped workflow management UI (team admin settings surface) - [ ] Drag-and-drop stage reorder in builder (backend supports it, UI is manual) - [ ] `on_complete` workflow chaining (column exists nullable, not wired) @@ -844,26 +870,129 @@ release notes. - [ ] Stage persona auto-switch in chat UI - [ ] Round-robin auto-assignment - [ ] Assignment notifications via WebSocket -- [ ] Extension surface routes (`/s/:slug` namespace) -- [ ] Persona tool grant enforcement in completion handler - [ ] Channel header stage indicator + advance/reject controls +**Moved to v0.27.0 Phase 1:** +- [ ] Extension surface routes (`/s/:slug` namespace) + +**Shipped (verified in code audit):** +- [x] Persona tool grant enforcement in completion handler (`completion.go:928`) + --- -## v0.27.0 — Tasks / Autonomous Agents +## v0.27.0 — Debt Clearance -Unattended execution — workflows without a human in the loop. -Depends on: workflow engine (v0.26.0). +Extension surface routes, workflow engine polish, workspace/editor debt. +Depends on: workflow engine (v0.26.0), dynamic surfaces (v0.25.0). -_(Shifted from v0.23.0 — no content changes, dependency refs updated to v0.26.0)_ +See [DESIGN-0.27.0.md](DESIGN-0.27.0.md) for full spec. -- [ ] Scheduler + task runner (cron-like triggers for workflow instantiation) -- [ ] `task_create` tool (AI can spawn sub-workflows) -- [ ] `type: 'service'` channels: workflow instances with no human participants -- [ ] Execution budgets: max tokens, max tool calls, max wall-clock per stage -- [ ] Admin controls for resource limits and kill switches -- [ ] Completion webhooks (notify external systems when workflow completes) -- [ ] Workflow chaining: one workflow's completion triggers another workflow's start +**Phase 1: Extension Surface Routes (`/s/:slug`)** +- [ ] `surface-extension` Go template (HTML + CSS + scripts blocks) +- [ ] `base.html` conditional chain extended with `{{if .Manifest}}` fallthrough +- [ ] `loadExtensionSurfaces()` in page engine (reads extension manifests from DB at startup) +- [ ] nginx location block for `/surfaces/` static assets +- [ ] Frontend nav renders extension surfaces from `ListEnabledSurfaces` +- [ ] Admin surfaces section: upload/enable/disable/uninstall (API already exists) +- [ ] Sample `.surface` archive (hello-world dashboard) for testing +- [ ] CSP nonce propagation for extension scripts + +**Phase 2: Workflow Engine Polish (v0.26.0 debt)** +- [ ] Channel header workflow stage indicator + advance/reject controls +- [ ] Stage persona auto-switch in chat UI +- [ ] Assignment notifications via WebSocket (`workflow.assigned`, `workflow.claimed`) +- [ ] Round-robin auto-assignment (per-stage `transition_rules.auto_assign`) +- [ ] `on_complete` workflow chaining (column exists, wire trigger logic) +- [ ] Workflow retention enforcement (extend staleness sweep goroutine) +- [ ] Drag-and-drop stage reorder in workflow builder UI +- [ ] Team-scoped workflow management UI (team settings → Workflows section) + +**Phase 3: Workspace + Editor Debt** +- [ ] `.gitignore` respect in workspace indexing (`workspace/indexer.go`) +- [ ] Workspace settings UI: git config section in channel/project settings +- [ ] Pane state persistence per-user/per-project (save/restore in `user_settings`) +- [ ] Editor drag-drop file reorder (workspace file tree DnD) + +--- + +## v0.27.1 — Tasks Foundation: Service Channels + Scheduler + +Core primitive for autonomous agents: a channel with no human +participant, driven by a cron scheduler. +Depends on: v0.27.0 (debt clearance). + +- [ ] `tasks` table + store interface + PG/SQLite implementations +- [ ] `type: 'service'` channel type (no human participants) +- [ ] `TaskScheduler` background goroutine (poll interval: 30s) +- [ ] Cron expression parsing (`robfig/cron/v3`) + `next_run_at` computation +- [ ] Prompt task execution (create service channel, send prompt, collect response) +- [ ] Workflow task execution (instantiate workflow in service channel) +- [ ] Provider resolution for task context (BYOK → team → global → routing policy) +- [ ] Tasks sidebar section (read-only view of service channels) +- [ ] Migration 026: `tasks` table, `service` channel type + +--- + +## v0.27.2 — Task Execution: Budgets + Admin Controls + +Guardrails for unattended execution. +Depends on: v0.27.1 (task scheduler). + +- [ ] Execution budget enforcement: `max_tokens`, `max_tool_calls`, `max_wall_clock` +- [ ] `task_runs` table + store (run history, status, budget usage) +- [ ] Budget breach → `budget_exceeded` status + owner notification +- [ ] Concurrent run skip (if previous run still active, skip with warning) +- [ ] Global config: `tasks.enabled`, `tasks.allow_personal`, `tasks.max_concurrent` +- [ ] Default budget ceilings in global config (overridable per task) +- [ ] `tasks.create` and `tasks.admin` permissions +- [ ] Admin panel → Tasks section (CRUD, history, kill switch, budget config) + +--- + +## v0.27.3 — Task Chaining: Webhooks + Workflow-to-Workflow + +External integration and multi-workflow orchestration. +Depends on: v0.27.2 (task budgets). + +- [ ] Completion webhooks (POST to external URL on task/workflow completion) +- [ ] Webhook retry (3 attempts, exponential backoff) + HMAC signature +- [ ] `task_create` tool (AI spawns sub-tasks, `RequireWorkflow` predicate, depth limit) +- [ ] `on_complete` chaining via task system (completed workflow → one-shot task → target workflow) +- [ ] Webhook secret generation per task/workflow + +--- + +## v0.27.4 — Personal Tasks: BYOK Scheduling + User Task UI + +User-facing task experience. Personal tasks run against BYOK providers. +Depends on: v0.27.2 (admin controls, `tasks.allow_personal`). + +- [ ] Settings → Tasks section (CRUD, schedule builder, budget config) +- [ ] Schedule builder: preset crons + custom 5-field cron + timezone selector +- [ ] BYOK provider routing for personal tasks +- [ ] `tasks.personal_require_byok` config key (prevent fallthrough to org providers) +- [ ] Output modes: channel (default), note, webhook +- [ ] Task notification booleans: `notify_on_complete` (default false), `notify_on_failure` (default true) +- [ ] Tasks sidebar section with status indicators (✓/✗/⏳/▶) +- [ ] "Run Now" button for manual trigger +- [ ] 3–5 optional starter templates (news digest, stock screener, standup prep, etc.) + +--- + +## v0.28.0 — Platform Polish + +TBD pull-forward: high-value items whose dependencies are met post-tasks. + +**Tier 1 — High value, dependencies met:** +- [ ] Virtual scroll for long conversations (prerequisite for heavy task output channels) +- [ ] KB auto-injection: top-K chunk prepend, context budget aware, per-channel toggle +- [ ] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`) +- [ ] Per-provider model preferences (`provider_config_id` dimension in `user_model_settings`) + +**Tier 2 — Medium value:** +- [ ] Memory compaction: summarize old memories, confidence decay, prune low-confidence +- [ ] `capability_match` routing policy ("cheapest model with tool_calling") +- [ ] New provider types registrable via config file (OpenAI-compatible + custom schema) --- @@ -895,7 +1024,7 @@ based on need. **UX / Multi-Seat** - "Group" scope badge on model selector / KB list: show access-source annotation so users see _why_ they have access (global, team, group grant). Requires backend to include `access_source` in list responses. -- Per-provider model preferences: `user_model_settings` unique key is `(user_id, model_id)` — same model from different providers shares one visibility toggle. Needs `provider_config_id` dimension in DB constraint, store, API, and frontend `hiddenModels` keying. Frontend composite key (`configId:modelId`) already exists in `App.models[].id`. +- ~~Per-provider model preferences~~ → scheduled v0.28.0. `user_model_settings` unique key is `(user_id, model_id)` — same model from different providers shares one visibility toggle. Needs `provider_config_id` dimension in DB constraint, store, API, and frontend `hiddenModels` keying. Frontend composite key (`configId:modelId`) already exists in `App.models[].id`. - Git credentials store: vault-encrypted per-user git credentials (similar to BYOK pattern). Git provider abstraction (GitHub, GitLab, Gitea). Clone/pull/push handlers. Natural fit for extension sidecar tier since git operations are long-running. - Admin settings team/user export: v0.25.4 ships admin-only settings export/import. User export blocked by vault-encrypted BYOK keys (can't round-trip). Team export needs merge-vs-replace semantics for member lists. @@ -907,7 +1036,7 @@ based on need. - Sub-projects / nested hierarchy: child inherits parent KBs, system prompt, persona (deferred until usage patterns clarify need vs tags/labels) **Knowledge Bases — Future** -- KB auto-injection: top-K chunk prepend to system prompt, context budget aware, per-channel toggle (latency budgeting required) +- ~~KB auto-injection~~ → scheduled v0.28.0 - Hybrid search: combine vector similarity with full-text `tsvector`, re-rank - Semantic chunking: embedding-based boundary detection for smarter splits - HNSW index: better query performance than IVFFlat for large datasets @@ -917,8 +1046,8 @@ based on need. (currently uses direct `database.DB.ExecContext` in handler — works but bypasses store layer) **Memory — Future** -- Memory compaction: summarize old memories to save context tokens -- Memory confidence decay: reduce confidence over time, prune low-confidence entries +- ~~Memory compaction~~ → scheduled v0.28.0 +- ~~Memory confidence decay~~ → scheduled v0.28.0 (combined with compaction) - Memory export/import: portable memory format across instances - Cross-persona memory sharing (opt-in): e.g. "coding assistant" can read facts from "project manager" - Memory analytics: dashboard showing what Personas are learning, memory growth trends @@ -928,9 +1057,9 @@ based on need. - ~~Provider health monitoring~~ → v0.22.0 + key rotation - Multi-tenant SaaS mode - Plugin/extension marketplace -- Virtual scroll for long conversations +- ~~Virtual scroll for long conversations~~ → scheduled v0.28.0 - ~~SQLite backend option (single-user / dev)~~ → v0.17.1 -- **Helm chart.** The k8s/ raw manifests with `${VAR}` substitution have served well but are friction for external adopters and make values management manual. A Helm chart wraps the same backend + frontend deployments with a `values.yaml` (replicas, image tags, ingress host, storage class, secret refs, resource limits, feature flags). Target: `helm install switchboard ./chart` for a fresh cluster, `helm upgrade` for rolling deploys. Subcharts for optional Postgres (for dev/test — prod uses external). Candidate for v0.28+ or a parallel track once the core feature set stabilizes. +- ~~**Helm chart.**~~ → scheduled v0.28.0 ~~**Pane Architecture (Workspace Container)**~~ → shipped in v0.25.0 diff --git a/docs/SURFACES.md b/docs/SURFACES.md deleted file mode 100644 index c88a426..0000000 --- a/docs/SURFACES.md +++ /dev/null @@ -1,350 +0,0 @@ -# Surface Development Guide - -**Version:** 0.25.1 -**Audience:** Anyone with admin access to a Chat Switchboard instance - ---- - -## Overview - -A surface is a full-screen application mode in Chat Switchboard. The platform ships with core surfaces (Chat, Notes, Settings, Admin, Workflow) that cannot be uninstalled. Extension surfaces are uploaded as `.surface` archives via the Admin panel and can be enabled, disabled, and uninstalled at runtime. - -This guide covers everything needed to build, package, install, and manage surfaces using only a running instance — no access to the Go codebase required. - ---- - -## Quick Start - -Create a minimal surface in under 5 minutes: - -### 1. Create the files - -**manifest.json:** -```json -{ - "id": "hello", - "version": "1.0.0", - "title": "Hello World", - "description": "Minimal surface example", - "route": "/hello", - "auth": "authenticated", - "scripts": ["js/hello.js"], - "styles": ["css/hello.css"], - "source": "extension" -} -``` - -**js/hello.js:** -```javascript -(function() { - document.addEventListener('DOMContentLoaded', function() { - if (window.__SURFACE__ !== 'hello') return; - - var root = document.getElementById('surfaceRoot'); - if (!root) return; - - root.innerHTML = - '
' + - '
' + - '← Back' + - 'Hello World' + - '
' + - '
' + - '
' + - '
👋
' + - '

Hello from a Surface

' + - '

Waiting for app init…

' + - '
' + - '
' + - '
'; - - document.addEventListener('sb:ready', function() { - var el = document.getElementById('helloStatus'); - if (el) { - el.textContent = 'Logged in as ' + (API.user?.username || 'unknown') + - ' | ' + (App.models?.length || 0) + ' models loaded'; - } - }, { once: true }); - }); -})(); -``` - -**css/hello.css:** -```css -/* Empty for minimal example. Real surfaces use CSS classes with var(--*) tokens. */ -``` - -### 2. Package - -```bash -zip -r hello.surface manifest.json js/ css/ -``` - -### 3. Install - -**Admin → System → Surfaces → + Install Surface**. Upload `hello.surface`. - -### 4. Visit - -Navigate to `https://your-instance/hello`. - ---- - -## How Surface Loading Works - -``` -Browser requests /hello - → Router matches route from surface_registry - → Auth middleware runs (based on manifest "auth" field) - → base.html renders with: - - Common CSS (variables, layout, components) - - Common JS (api.js, events.js, ui-core.js, all component factories) - - Your surface CSS and JS from manifest - - app.js (boot script) - → DOMContentLoaded fires: - 1. Your surface JS runs — build DOM, create components - 2. app.js init() runs — auth, settings, models, WebSocket - 3. 'sb:ready' CustomEvent fires when init is complete -``` - -### Critical: Boot Timing - -Your JS runs BEFORE app init completes. - -| Available at DOMContentLoaded | Available at sb:ready | -|---|---| -| `window.__SURFACE__`, `window.__BASE__` | `App.models`, `App.settings` | -| All component factories | `API.user`, `API.isAdmin` | -| DOM ready | `Events` WebSocket connected | - -**Rule:** Anything needing models, user info, or settings goes in `sb:ready`: - -```javascript -document.addEventListener('sb:ready', function() { - // Safe to use App.models, API.user, App.settings -}, { once: true }); -``` - ---- - -## manifest.json Reference - -| Field | Required | Description | -|---|---|---| -| `id` | Yes | Unique identifier. Lowercase, no spaces. | -| `title` | Yes | Display name in admin panel and nav. | -| `version` | No | Semver for display. | -| `description` | No | Short description for admin. | -| `route` | No | URL pattern. Gin params: `/thing/:id`. Defaults to `/{id}`. | -| `alt_routes` | No | Additional URL patterns. | -| `auth` | No | `"authenticated"` (default), `"admin"`, `"session"`, `"public"`. | -| `scripts` | No | JS files relative to archive root. | -| `styles` | No | CSS files relative to archive root. | -| `components` | No | Component IDs used (documentation only). | -| `source` | No | Always `"extension"` for uploaded surfaces. | - ---- - -## .surface Archive Format - -``` -my-surface.surface (ZIP file) -├── manifest.json ← Required -├── js/ -│ └── my-surface.js ← Boot script(s) -├── css/ -│ └── my-surface.css ← Styles -└── assets/ ← Optional: images, fonts -``` - -Standard ZIP. `.surface` extension is conventional; `.zip` also accepted. - ---- - -## CSS Theme Tokens - -Use these instead of hardcoded colors: - -```css -/* Backgrounds */ -var(--bg) var(--bg-secondary) var(--bg-tertiary) var(--bg-raised) var(--bg-hover) - -/* Text */ -var(--text) var(--text-2) var(--text-3) - -/* Accent */ -var(--accent) var(--accent-dim) var(--accent-hover) - -/* Borders */ -var(--border) var(--border-light) - -/* Semantic */ -var(--success) var(--warning) var(--danger) var(--purple) -var(--success-dim) var(--warning-dim) var(--danger-dim) var(--purple-dim) - -/* Type & Layout */ -var(--mono) var(--msg-font) var(--radius) var(--radius-lg) var(--transition) -``` - ---- - -## Reusable Components - -All loaded by `base.html` on every surface. No imports needed. - -### UserMenu - -```javascript -// Build DOM into a container -container.innerHTML = - '
' + - '' + - '
' + - '' + - '' + - '' + - '
' + - '' + - '
' + - '
'; - -// Wire after auth (sb:ready) -var menu = UserMenu.create({ id: '' }); -menu.setUser(API.user); -menu.bind({ - onSettings: function() { location.href = (window.__BASE__ || '') + '/settings'; }, - onAdmin: function() { location.href = (window.__BASE__ || '') + '/admin'; }, - onSignout: function() { if (typeof handleLogout === 'function') handleLogout(); }, -}); -menu.showAdmin(API.isAdmin); -``` - -### ChatPane - -```javascript -// Build DOM -slot.innerHTML = - '
' + - '
' + - '
' + - '
' + - '
' + - '' + - '
' + - '
' + - '
'; - -// Create instance -var pane = ChatPane.create({ - id: 'x', - messagesEl: document.getElementById('xChatMessages'), - inputEl: document.getElementById('xChatInput'), - sendBtnEl: document.getElementById('xSendBtn'), - standalone: true, -}); -pane.showWelcome(); -``` - -### PaneContainer - -```javascript -PaneContainer.registerPreset('my-layout', { - type: 'split', direction: 'horizontal', - sizes: [250, null, 350], - children: [ - { type: 'leaf', id: 'nav', size: 250, minSize: 150 }, - { type: 'leaf', id: 'main' }, // flex pane (no size) - { type: 'tabbed', id: 'assist', size: 350, minSize: 200, - tabs: [{ id: 'chat', label: 'Chat' }, { id: 'tools', label: 'Tools' }] - }, - ], -}); - -var layout = PaneContainer.mount(document.getElementById('body'), 'my-layout'); -var mainEl = layout._panes.get('main').el; -var chatTab = layout._panes.get('assist').getTabPanel('chat'); -``` - -**Rules:** One child per split must omit `size` (flex pane). Drag handles cap at 60%. Sizes persist in localStorage. - ---- - -## Making API Calls - -```javascript -// CRUD -var data = await API._get('/api/v1/channels'); -var resp = await API._post('/api/v1/channels', { title: 'New', type: 'direct' }); -await API._put('/api/v1/channels/' + id, { title: 'Updated' }); -await API._del('/api/v1/channels/' + id); - -// Streaming (SSE) -var resp = await API.streamCompletion(channelId, content, model, abortSignal); -var reader = resp.body.getReader(); -``` - -Auth tokens and base path are handled automatically. - ---- - -## Admin: Surface Management - -**Location:** Admin → System → Surfaces - -| Action | Description | -|---|---| -| **+ Install Surface** | Upload `.surface` or `.zip` archive | -| **Toggle** | Enable/disable. Disabled surfaces redirect to Chat. | -| **Uninstall** | Remove extension surface (files + DB entry). | - -**Protected:** Chat and Admin toggles are locked — cannot be disabled. - -### API - -```bash -# List -curl -H "$AUTH" "$BASE/api/v1/admin/surfaces" - -# Install -curl -H "$AUTH" -F "file=@hello.surface" "$BASE/api/v1/admin/surfaces/install" - -# Disable / Enable -curl -X PUT -H "$AUTH" "$BASE/api/v1/admin/surfaces/editor/disable" -curl -X PUT -H "$AUTH" "$BASE/api/v1/admin/surfaces/editor/enable" - -# Uninstall -curl -X DELETE -H "$AUTH" "$BASE/api/v1/admin/surfaces/hello" - -# Public: list enabled (for nav) -curl -H "$AUTH" "$BASE/api/v1/surfaces" -``` - ---- - -## Checklist - -- [ ] `manifest.json` has `id` and `title` -- [ ] Boot script checks `window.__SURFACE__` guard -- [ ] DOM built in JS (no Go template dependency) -- [ ] Deferred init via `sb:ready` -- [ ] Topbar: `position: relative; z-index: 20` -- [ ] All colors use `var(--*)` tokens -- [ ] Back link: `window.__BASE__ + '/'` -- [ ] Graceful with 0 models -- [ ] Archive: only `manifest.json`, `js/`, `css/`, `assets/` - ---- - -## Troubleshooting - -| Symptom | Cause | Fix | -|---|---|---| -| Surface doesn't appear after install | Routes registered at startup | Restart server or toggle disable/enable | -| "Cannot overwrite core surface" | ID conflicts with chat/notes/etc. | Choose different ID | -| `API is undefined` | Code runs before app init | Move to `sb:ready` listener | -| No styles applied | CSS path mismatch | Check manifest `styles` matches archive structure | -| Flyout hidden behind content | Missing stacking context | Add `z-index: 20` to topbar | -| Drag handle snaps | v0.25.0 known issue | Sub-pixel rounding; partially mitigated | diff --git a/hello-dashboard.surface b/hello-dashboard.surface new file mode 100644 index 0000000000000000000000000000000000000000..c19cbb4d3fb058f53fde0f0ddfb23a17571c01e2 GIT binary patch literal 3152 zcma)8c{r47A08b0U=CwvN@Z&>jFOCEM)uPgGsaF?Gcz&cFl5UXibA1`I8GEIqe<3E zW69oFiz%aQ2ielma*8-Z-}Rl-iN5c7ulISMKi=zpe$RdVp8LLSta*MA0&I+!SfcCZ z;y)7zpa8)8`1z4Fy*+~Po@9?eZEEG^`D*{{RiW zaDHzvTMwE$(Cfp}QlytPlvkJzH+gqloK$(H?Y`pG14NyM6Uuj84ZwXJwRmni5D53OH1rX8W*|3#S&p4zKD&uBGIxuo zR7$Uat)MNXE6SgOS(4s+N@@FJo%}AYI0Yn2_8&)T?sqb<_Jq#~NhPG5v+a7ZD!OZC_+lEANV4TL@=XY4mPy}?46co<@TGY-F4ZMRBXk3&mEl|ffZ`U- z>+eA#obU;vXc2?Rr0?A@{~m;BlQxiVw7!@QAK-@S@*sA53Z&#qDW!U5odt=X9gWY8 zzd=EIR*d(oWw4%WP? zcKWFkVuP*cfld?GWUrRvv2WW9ezhP{oFtKL{YtyNA9~K^@EvP{pdzw3o7@BzsJyv4ry%ddRn; z&3wo6xmE*Y4>D=Ofr!Pp9O+$eV@#Kw8N#ue8AlxDg}o}13gupfwx*;L*@1|NQ?ete z3Bp2)DoXRCdg5By#_sXZlnR>wFU+UqcQCS`+6!T`LMw&mFoCgUh=+EhfTOSXIIAO1 z`e+>&`IwAxX$IVURlrV67x|kJ6E+Y-3yVuoT`N~W(qAhp;WVxw!{@uC|6C2| z&wC#N@zeKN$gG@Zvthdahl%$dtv(*(d2OZ2eI>9h0$<@u2l1WZy7q{Dmv?8~k7E`Q zmGjQn`Gk~d%Z9_mjKY+$)%&|kGPQ4$%U?9Oi_NxtD#=Bq|8kQ5SHa)SVg7$y59%Bp zem|KyAkk>+p_gXgrfOb%_|?6bm7RX#phghmS(H)h$ih&vonrNapnji%AOlyl?To5{ zWU)y2M7u@N3!%b$4&g=AAy(RFr=Q?Aa#4!I*Wq1L7$dFf2YRj3hg$>$ox4aiT>iXe zv|YZ(ROe8}mc{1OBs#iV%Wkh3HYu|)t>N(Hp5u`E7za7GEBfM)p{A@QKF58!}0i6OfdBk>-OW=-THf}M9@o~GTb zxOGQ?>5>(U^w=9R7Up=LKi?j_&tR@Vs%;5VP+e+gXZX3bdyr0-6W*Oy)T~abS5o!z z06M03OhQKP^(Esm>#ScZdh)6?gFlMXs-IRc$CWi7SJs)E%-iZel`J7RRYHutS^N%4 zVJeRqW5`}LzQU1RbIp>J^>?6hC$OT}&ygt@9sF9BrR9V>vxs_sg&A;%^yGN~MRSOd z#KZ2~-9e4=c8b!V6)oSEi?K(v(y7w$JV;9wrcd$5qww>BEdnlvsGI$iFhNwoy=i1piOsY~#n`e$CWcIZl zw;Y8<6K&=H5@pREhAz%$ke@> zzN+%B#0owW+Nk4n<2h>hH%erWr?A!Kxsc#LRhcFfx&s<|v~f9luHe0-glUgat%=&x zyPyWI@Q*{yc8M;%rE9N8tJJE8*{Gp~dkd!3h6^fYqm#$8l4G95L953A_GZiD=HX5{R!kr}tFeLE}v=MHu}-!k$qWB=YEwkBQT wD|-Am{`POuH%;<;z<<~NwMG83_PVWs|7)1r;q&rwXa(?b9xYBb2X3C>FIMN`?*IS* literal 0 HcmV?d00001 diff --git a/nginx.conf b/nginx.conf index 45e4e1c..cd1ed78 100644 --- a/nginx.conf +++ b/nginx.conf @@ -73,6 +73,23 @@ server { location /editor { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /notes { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /settings { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } + location /w/ { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } + + # v0.27.0: Extension surface page routes → backend (Go templates) + location /s/ { + proxy_pass $backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # v0.27.0: Extension surface static assets (JS, CSS, images) + location /surfaces/ { + alias /data/surfaces/; + expires 1h; + add_header Cache-Control "public"; + } # Fallback: redirect unknown paths to root location / { diff --git a/server/main.go b/server/main.go index 67282ae..867d1e0 100644 --- a/server/main.go +++ b/server/main.go @@ -5,7 +5,9 @@ import ( "encoding/json" "fmt" "log" + "net/http" "os" + "path/filepath" "strings" "time" @@ -1049,6 +1051,25 @@ func main() { // Server-rendered pages via Go templates. Runs alongside the SPA. // The chat surface is a bridge: Go renders the shell, existing JS // builds the DOM inside it — identical behavior to index.html. + + // v0.27.0: Extension surface static assets (JS, CSS, images). + // Served without auth — same rationale as extension assets (script tags can't send headers). + // In split deployment, nginx serves these from /data/surfaces/ directly. + if cfg.StoragePath != "" { + surfacesStaticDir := cfg.StoragePath + "/surfaces" + base.GET("/surfaces/:id/*path", func(c *gin.Context) { + id := c.Param("id") + filePath := c.Param("path") + // Security: prevent path traversal + clean := filepath.Clean(filepath.Join(surfacesStaticDir, id, filePath)) + if !strings.HasPrefix(clean, filepath.Clean(surfacesStaticDir)) { + c.Status(http.StatusForbidden) + return + } + c.File(clean) + }) + } + pages.SetVersion(Version) pageEngine := pages.New(cfg, stores) @@ -1105,7 +1126,7 @@ func main() { } log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath) log.Printf(" Health: provider tracking active (flush=%s, prune=%s)", health.FlushInterval, health.PruneAge) - log.Printf(" Pages: template engine active (surfaces: chat, editor, notes, settings, admin)") + log.Printf(" Pages: template engine active (%d surfaces registered)", len(pageEngine.Surfaces())) if err := r.Run(":" + cfg.Port); err != nil { log.Fatalf("Failed to start server: %v", err) } diff --git a/server/pages/pages.go b/server/pages/pages.go index 36aa922..b67e19a 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -91,6 +91,9 @@ type PageData struct { // v0.25.0: Enabled surface IDs for conditional nav rendering. EnabledSurfaces []string `json:"-"` + // v0.27.0: Extension surfaces for sidebar nav rendering. + ExtensionSurfaces []ExtensionNavItem `json:"-"` + // v0.22.7: Login/splash page fields InstanceName string // branding: instance display name LogoURL string // branding: custom logo URL @@ -110,6 +113,52 @@ func (pd PageData) SurfaceEnabled(id string) bool { return false } +// ExtensionNavItem holds the minimal info needed to render an extension +// surface link in the sidebar nav. +type ExtensionNavItem struct { + ID string + Title string + Route string +} + +// extensionNavItems returns enabled extension surfaces for sidebar rendering. +// Queries the DB at render time so newly installed surfaces appear immediately. +func (e *Engine) extensionNavItems() []ExtensionNavItem { + if e.stores.Surfaces == nil { + return nil + } + ctx := context.Background() + surfaces, err := e.stores.Surfaces.List(ctx) + if err != nil { + log.Printf("[pages] Failed to load extension surfaces for nav: %v", err) + return nil + } + + var items []ExtensionNavItem + for _, sr := range surfaces { + if sr.Source == "core" || !sr.Enabled { + continue + } + // Editor is Source="extension" but hardcoded as core — skip from dynamic nav + if sr.ID == "editor" { + continue + } + route := "" + if sr.Manifest != nil { + route, _ = sr.Manifest["route"].(string) + } + if route == "" { + route = "/s/" + sr.ID + } + items = append(items, ExtensionNavItem{ + ID: sr.ID, + Title: sr.Title, + Route: route, + }) + } + return items +} + // UserContext is the authenticated user's info available to templates. type UserContext struct { ID string `json:"id"` @@ -297,12 +346,74 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc { } e.Render(c, "base.html", PageData{ - Surface: surfaceID, - Section: section, - User: user, - Data: data, - Manifest: e.GetSurface(surfaceID), - EnabledSurfaces: e.EnabledSurfaceIDs(), + Surface: surfaceID, + Section: section, + User: user, + Data: data, + Manifest: e.GetSurface(surfaceID), + EnabledSurfaces: e.EnabledSurfaceIDs(), + ExtensionSurfaces: e.extensionNavItems(), + }) + } +} + +// RenderExtensionSurface handles /s/:slug — dynamic DB lookup for extension surfaces. +// No restart required after installing a surface via admin API. +func (e *Engine) RenderExtensionSurface() gin.HandlerFunc { + return func(c *gin.Context) { + slug := c.Param("slug") + if slug == "" { + c.String(http.StatusNotFound, "Surface not found") + return + } + + if e.stores.Surfaces == nil { + c.String(http.StatusNotFound, "Surface registry not available") + return + } + + // Look up by ID (slug == surface ID) + sr, err := e.stores.Surfaces.Get(c.Request.Context(), slug) + if err != nil || sr == nil { + c.String(http.StatusNotFound, "Surface not found: "+slug) + return + } + if !sr.Enabled { + c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/") + return + } + if sr.Source == "core" { + // Core surfaces have their own routes — don't serve them here + c.String(http.StatusNotFound, "Surface not found: "+slug) + return + } + + user := e.getUserContext(c) + + // Build manifest from DB record + route, _ := sr.Manifest["route"].(string) + if route == "" { + route = "/s/" + sr.ID + } + layout, _ := sr.Manifest["layout"].(string) + if layout == "" { + layout = "single" + } + manifest := &SurfaceManifest{ + ID: sr.ID, + Route: route, + Title: sr.Title, + Template: "surface-extension", + Layout: layout, + Source: "extension", + } + + e.Render(c, "base.html", PageData{ + Surface: sr.ID, + User: user, + Manifest: manifest, + EnabledSurfaces: e.EnabledSurfaceIDs(), + ExtensionSurfaces: e.extensionNavItems(), }) } } @@ -341,6 +452,10 @@ func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddlewar handler := e.RenderSurface(s.ID) registerRoutes(group, s, handler) } + + // v0.27.0: Extension surface catch-all — DB lookup at request time. + // No restart needed after installing new surfaces via admin API. + group.GET("/s/:slug", e.RenderExtensionSurface()) } // Admin surfaces — auth + admin role middleware diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html index 0ca265b..598b42a 100644 --- a/server/pages/templates/base.html +++ b/server/pages/templates/base.html @@ -22,8 +22,13 @@ + {{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}} {{if eq .Surface "editor"}}{{template "css-editor" .}}{{end}} + {{/* v0.27.0: Extension surface CSS — loaded from /surfaces/{id}/css/main.css */}} + {{if and .Manifest (eq .Manifest.Source "extension")}} + + {{end}}