Changeset 0.27.0 (#166)

This commit is contained in:
2026-03-10 16:38:06 +00:00
parent 400f7dd176
commit 7e4f1581f2
15 changed files with 1721 additions and 520 deletions

View File

@@ -1 +1 @@
0.26.5 0.27.0

View File

@@ -78,6 +78,10 @@ $(page_proxy_block "${BASE_PATH}/editor")
$(page_proxy_block "${BASE_PATH}/notes") $(page_proxy_block "${BASE_PATH}/notes")
$(page_proxy_block "${BASE_PATH}/settings") $(page_proxy_block "${BASE_PATH}/settings")
$(page_proxy_block "${BASE_PATH}/w") $(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 ROUTES
) )

842
docs/DESIGN-0.27.0.md Normal file
View File

@@ -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:928949` — second-pass allowlist via `GetToolGrants()` | v0.25.0, v0.26.0 |
| Version snapshot includes persona tool grants | `workflows.go:263273``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 6166 and 118122 of `base.html` use `{{if eq .Surface "chat"}}`
chains. Extension surfaces fall through to "Unknown surface". Same
pattern for CSS includes (line 2526) and script includes (118122).
**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}}<div>Unknown surface: {{.Surface}}</div>
{{end}}
```
New template `templates/surfaces/extension.html`:
```html
{{define "surface-extension"}}
<div id="extension-surface" class="extension-surface"
data-surface-id="{{.Surface}}"
data-manifest='{{.Manifest | toJSON}}'>
{{/* Extension JS mounts into this container */}}
<div id="extension-mount"></div>
</div>
{{end}}
{{define "css-extension"}}
{{/* Extension CSS loaded dynamically from surfacesDir */}}
{{if .Manifest}}
<link rel="stylesheet" href="{{.BasePath}}/surfaces/{{.Surface}}/css/main.css?v={{.Version}}">
{{end}}
{{end}}
{{define "scripts-extension"}}
{{if .Manifest}}
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>
{{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 = '<h1>My Dashboard</h1>';
})();
```
**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 35 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).

453
docs/EXTENSION-SURFACES.md Normal file
View File

@@ -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 = '<h1>Hello, ' + esc(user.display_name || user.username) + '!</h1>';
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:
```
<body data-surface="my-dashboard">
<div class="surface">
<div class="surface-inner">
<div id="extension-surface" class="extension-surface">
<div class="user-menu-wrap"> ... </div> ← platform nav
<div id="extension-mount" class="extension-mount">
← YOUR CONTENT GOES HERE
</div>
</div>
</div>
</div>
<script>window.__MANIFEST__ = {...};</script>
<script>window.__USER__ = {...};</script>
<script src="/js/api.js"></script>
<script src="/js/ui-core.js"></script>
... platform scripts ...
<script src="/surfaces/my-dashboard/js/main.js"></script> ← YOUR SCRIPT
</body>
```
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
<button class="btn-primary">Primary Action</button>
<button class="btn-secondary">Secondary</button>
<button class="btn-danger">Destructive</button>
<button class="btn-ghost">Ghost</button>
<button class="btn-small">Small</button>
```
### Badges
```html
<span class="badge">Default</span>
<span class="badge badge-success">Success</span>
<span class="badge badge-danger">Error</span>
<span class="badge badge-warning">Warning</span>
```
### 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

View File

@@ -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 Extension surfaces are full-page custom UIs installed as `.surface`
replaces or augments the default chat area. The surface system preserves archives and served at `/s/:slug`. They run inside the platform shell
DOM state across mode switches (critical for CM6 editor instances). 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
``` - **Archive format:** zip with `manifest.json` + `js/main.js` + optional `css/main.css`
┌─────────────────────────────────────────────┐ - **Install:** Admin panel → Surfaces → Upload (no restart required)
│ sidebar-top │ surface-header │ - **Route:** `/s/{id}` — rendered via Go templates, DB lookup at request time
│ │ (model-bar) │ - **Entry point:** `js/main.js` mounts into `#extension-mount`
│ mode-selector │ │ - **Platform access:** `API._get()`, `UI.toast()`, `Theme.resolved()`, `Events.on()`, `ChatPane.create()`
│ (auto-shown │ surface-main │ - **Theming:** CSS custom properties from `variables.css` (`--bg`, `--bg-surface`, `--text`, `--text-2`, `--accent`, etc.)
│ when ≥2 │ (chatMessages) │ - **Admin API:** install, enable/disable, uninstall via `/api/v1/admin/surfaces/*`
│ surfaces) │ │
│ │ │
│ sidebar-content │ surface-footer │
│ (search+chats) │ (input-area) │
│ │ │
│ sidebar-bottom │ │
└─────────────────────────────────────────────┘
```
### 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 | Type | Creates a page? | Uses |
{ |------|----------------|------|
"surfaces": [ | **Surface** (`/s/:slug`) | Yes — full page | Custom dashboards, tools, visualizations |
{ | **Block renderer** (Hook 3) | No — transforms message content | Mermaid diagrams, KaTeX math, CSV tables |
"id": "editor", | **Tool** (Hook 5) | No — LLM-invocable function | Calculator, web search, file operations |
"label": "Editor", | **Post-render** (Hook 3) | No — enhances rendered messages | Syntax highlighting, link previews |
"icon": "code",
"regions": ["surface-main", "surface-footer", "sidebar-content"],
"default": false
}
]
}
```
**Imperative** (implemented): A single extension can combine multiple types — e.g. a surface for
configuration plus a tool for LLM interaction plus a renderer for
```js custom output formatting.
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.
--- ---

View File

@@ -4,6 +4,8 @@
- [ARCHITECTURE.md](ARCHITECTURE.md) — Core services design, store layer, scope model - [ARCHITECTURE.md](ARCHITECTURE.md) — Core services design, store layer, scope model
- [EXTENSIONS.md](EXTENSIONS.md) — Extension system spec (Browser/Starlark/Sidecar tiers, - [EXTENSIONS.md](EXTENSIONS.md) — Extension system spec (Browser/Starlark/Sidecar tiers,
manifests, browser tool bridge, surfaces/modes, model roles) 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 - [CHANGELOG.md](../CHANGELOG.md) — Detailed release notes for all completed versions
**Versioning (pre-1.0):** `0.<major>.<minor>` — hotfixes use quad: `0.x.y.z` **Versioning (pre-1.0):** `0.<major>.<minor>` — hotfixes use quad: `0.x.y.z`
@@ -152,9 +154,29 @@ v0.26.0 Workflow Engine ✅
human + AI collab, visitor human + AI collab, visitor
intake, assignment queue) 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, (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. makes them a natural addition.
**Surfaces + Editor** **Surfaces + Editor**
- [ ] Extension loader: surfaces from manifest.json (was v0.21.3`/s/:slug` route deferred to v0.27.0) - [x] ~~Extension loader: surfaces from manifest.json~~ (v0.27.0`/s/:slug` route)
- [ ] Editor drag-drop file reorder (was v0.21.5) - [x] ~~Editor drag-drop file reorder~~ (v0.27.0 — workspace debt)
- [ ] Article drag-to-reorder outline sections (was v0.21.6) - [ ] Article drag-to-reorder outline sections (was v0.21.6)
- [ ] Article-specific AI tools: suggest_outline, expand_section, check_citations (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) - [x] ~~PDF export via pandoc~~ (shipped v0.22.4)
- [ ] Mobile: mode selector collapses to hamburger/bottom nav (was v0.21.3) - [ ] 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 + Git**
- [ ] Workspace settings UI: git config section in channel/project settings (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) - [ ] User settings: git credentials management UI (was v0.21.4 — needs vault extension, v0.28.0+)
- [ ] `.gitignore` respect in workspace indexing (was v0.21.4) - [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) - [ ] Integration tests: clone, commit, push/pull cycle — requires git binary in CI (was v0.21.4)
**Provider Health + Routing** **Provider Health + Routing**
- [x] ~~`RecordOutcome` for web_search and url_fetch~~ (shipped v0.22.4 — tool health tracking) - [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] ~~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) - [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 - [ ] Latency-aware routing: track response time percentiles, prefer faster providers
- [ ] Cost-aware routing with budget ceiling per request - [ ] 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 - [ ] Provider profile editor: key-value config per provider type, preview of effective settings
- [ ] Fallback chain visualizer: drag-to-reorder provider priority per model family - [ ] 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`) - [x] ~~Session cleanup job~~ (shipped v0.26.0 — background goroutine, `SESSION_EXPIRY_DAYS`)
**Tool System** **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] Core surfaces (chat, admin, settings, editor, notes) on manifest pattern
- [x] `window.__PAGE_DATA__` injection from declared `data_requires` - [x] `window.__PAGE_DATA__` injection from declared `data_requires`
- [x] `surface_registry` table — admin can enable/disable surfaces (Migration 022) - [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** **Component Extraction**
- [x] FileTree: extracted from `editor-mode.js`, standalone component with Go template partial + JS hydration - [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** **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 - [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] Persona create/edit UI: "Tools" section — multi-select checklist grouped by category
- [x] `loadToolList()` fetches from `/api/v1/tools`, `loadToolGrants()` reads per-persona grants - [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 - [x] Completion handler applies `GetToolGrants()` as second-pass allowlist (`completion.go:928`)
- [ ] Version snapshot includes persona tool grants at snapshot time — deferred to v0.27.0 - [x] Version snapshot includes persona tool grants at snapshot time (`workflows.go:263`)
**Bug Fixes (v0.25.3v0.25.4)** **Bug Fixes (v0.25.3v0.25.4)**
- [x] Theme system mode (system preference → dark fallback) - [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 **Shared Surface Routes** — partially shipped
- [x] `/admin/:section`, `/settings/:section` — full-page surface routes - [x] `/admin/:section`, `/settings/:section` — full-page surface routes
- [ ] `/s/editor/:wsId`, `/s/article/:wsId/:path`deferred - [ ] `/s/editor/:wsId`, `/s/article/:wsId/:path`v0.27.0 (extension surface routes)
- [ ] `/p/:id` — shared project view — deferred - [ ] `/p/:id` — shared project view — TBD
**Shipped in v0.26.0:** **Shipped in v0.26.0:**
- [x] Context-Aware Tool System (`ToolContext`, `Require` predicates, `AvailableFor()`) - [x] Context-Aware Tool System (`ToolContext`, `Require` predicates, `AvailableFor()`)
- [x] `ExecutionContext` extension (`WorkflowID`, `TeamID` fields) - [x] `ExecutionContext` extension (`WorkflowID`, `TeamID` fields)
**Deferred to v0.27.0:** **Shipped (verified in code audit):**
- [ ] Tool grant enforcement in completion handler - [x] Tool grant enforcement in completion handler (`completion.go:928`)
- [ ] Extension surface route namespace (`/s/:slug`) - [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:** **Migration:**
- [x] 022_surfaces.sql: `surface_registry` table (UUID PK, unique surface_id, is_enabled, is_core) - [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] Route registration test (`TestRouteRegistration`)
- [x] Workflow CRUD test (`TestWorkflowCRUD`, `TestWorkflowValidation`) - [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) - [ ] Team-scoped workflow management UI (team admin settings surface)
- [ ] Drag-and-drop stage reorder in builder (backend supports it, UI is manual) - [ ] Drag-and-drop stage reorder in builder (backend supports it, UI is manual)
- [ ] `on_complete` workflow chaining (column exists nullable, not wired) - [ ] `on_complete` workflow chaining (column exists nullable, not wired)
@@ -844,26 +870,129 @@ release notes.
- [ ] Stage persona auto-switch in chat UI - [ ] Stage persona auto-switch in chat UI
- [ ] Round-robin auto-assignment - [ ] Round-robin auto-assignment
- [ ] Assignment notifications via WebSocket - [ ] Assignment notifications via WebSocket
- [ ] Extension surface routes (`/s/:slug` namespace)
- [ ] Persona tool grant enforcement in completion handler
- [ ] Channel header stage indicator + advance/reject controls - [ ] 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. Extension surface routes, workflow engine polish, workspace/editor debt.
Depends on: workflow engine (v0.26.0). 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) **Phase 1: Extension Surface Routes (`/s/:slug`)**
- [ ] `task_create` tool (AI can spawn sub-workflows) - [ ] `surface-extension` Go template (HTML + CSS + scripts blocks)
- [ ] `type: 'service'` channels: workflow instances with no human participants - [ ] `base.html` conditional chain extended with `{{if .Manifest}}` fallthrough
- [ ] Execution budgets: max tokens, max tool calls, max wall-clock per stage - [ ] `loadExtensionSurfaces()` in page engine (reads extension manifests from DB at startup)
- [ ] Admin controls for resource limits and kill switches - [ ] nginx location block for `/surfaces/` static assets
- [ ] Completion webhooks (notify external systems when workflow completes) - [ ] Frontend nav renders extension surfaces from `ListEnabledSurfaces`
- [ ] Workflow chaining: one workflow's completion triggers another workflow's start - [ ] 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
- [ ] 35 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** **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. - "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. - 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. - 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) - Sub-projects / nested hierarchy: child inherits parent KBs, system prompt, persona (deferred until usage patterns clarify need vs tags/labels)
**Knowledge Bases — Future** **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 - Hybrid search: combine vector similarity with full-text `tsvector`, re-rank
- Semantic chunking: embedding-based boundary detection for smarter splits - Semantic chunking: embedding-based boundary detection for smarter splits
- HNSW index: better query performance than IVFFlat for large datasets - 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) (currently uses direct `database.DB.ExecContext` in handler — works but bypasses store layer)
**Memory — Future** **Memory — Future**
- Memory compaction: summarize old memories to save context tokens - ~~Memory compaction~~ → scheduled v0.28.0
- Memory confidence decay: reduce confidence over time, prune low-confidence entries - ~~Memory confidence decay~~ → scheduled v0.28.0 (combined with compaction)
- Memory export/import: portable memory format across instances - Memory export/import: portable memory format across instances
- Cross-persona memory sharing (opt-in): e.g. "coding assistant" can read facts from "project manager" - 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 - 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 - ~~Provider health monitoring~~ → v0.22.0 + key rotation
- Multi-tenant SaaS mode - Multi-tenant SaaS mode
- Plugin/extension marketplace - 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 - ~~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 ~~**Pane Architecture (Workspace Container)**~~ → shipped in v0.25.0

View File

@@ -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 =
'<div style="display:flex;flex-direction:column;height:100%;background:var(--bg);color:var(--text);font-family:inherit;">' +
'<div style="display:flex;align-items:center;gap:8px;padding:0 12px;height:40px;background:var(--bg-secondary);border-bottom:1px solid var(--border);position:relative;z-index:20;">' +
'<a href="' + (window.__BASE__ || '') + '/" style="color:var(--text-3);text-decoration:none;font-size:12px;">← Back</a>' +
'<span style="font-size:13px;font-weight:600;">Hello World</span>' +
'</div>' +
'<div style="flex:1;display:flex;align-items:center;justify-content:center;">' +
'<div style="text-align:center;">' +
'<div style="font-size:48px;margin-bottom:16px;">👋</div>' +
'<h1 style="font-size:24px;font-weight:600;margin:0 0 8px;">Hello from a Surface</h1>' +
'<p style="color:var(--text-3);margin:0;" id="helloStatus">Waiting for app init…</p>' +
'</div>' +
'</div>' +
'</div>';
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 =
'<div class="user-menu-wrap" id="userMenuWrap">' +
'<button id="userMenuBtn" class="user-btn">' +
'<div id="userAvatar" class="user-avatar"><span id="avatarLetter">?</span></div>' +
'<span id="userName" class="sb-label">User</span>' +
'</button>' +
'<div id="userFlyout" class="user-flyout">' +
'<button id="menuSettings" class="flyout-item">Settings</button>' +
'<button id="menuAdmin" class="flyout-item" style="display:none">Admin</button>' +
'<button id="menuDebug" class="flyout-item" style="display:none">Debug</button>' +
'<hr class="flyout-divider">' +
'<button id="menuSignout" class="flyout-item flyout-danger">Sign Out</button>' +
'</div>' +
'</div>';
// 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 =
'<div class="chat-pane" id="xChatPane">' +
'<div class="chat-pane-messages" id="xChatMessages"></div>' +
'<div class="chat-pane-input-bar">' +
'<div class="chat-pane-input-wrap">' +
'<div class="chat-pane-input" id="xChatInput"></div>' +
'<button class="chat-pane-send" id="xSendBtn">Send</button>' +
'</div>' +
'</div>' +
'</div>';
// 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 |

BIN
hello-dashboard.surface Normal file

Binary file not shown.

View File

@@ -73,6 +73,23 @@ server {
location /editor { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } 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 /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 /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 # Fallback: redirect unknown paths to root
location / { location / {

View File

@@ -5,7 +5,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"net/http"
"os" "os"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -1049,6 +1051,25 @@ func main() {
// Server-rendered pages via Go templates. Runs alongside the SPA. // Server-rendered pages via Go templates. Runs alongside the SPA.
// The chat surface is a bridge: Go renders the shell, existing JS // The chat surface is a bridge: Go renders the shell, existing JS
// builds the DOM inside it — identical behavior to index.html. // 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) pages.SetVersion(Version)
pageEngine := pages.New(cfg, stores) pageEngine := pages.New(cfg, stores)
@@ -1105,7 +1126,7 @@ func main() {
} }
log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath) 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(" 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 { if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("Failed to start server: %v", err) log.Fatalf("Failed to start server: %v", err)
} }

View File

@@ -91,6 +91,9 @@ type PageData struct {
// v0.25.0: Enabled surface IDs for conditional nav rendering. // v0.25.0: Enabled surface IDs for conditional nav rendering.
EnabledSurfaces []string `json:"-"` EnabledSurfaces []string `json:"-"`
// v0.27.0: Extension surfaces for sidebar nav rendering.
ExtensionSurfaces []ExtensionNavItem `json:"-"`
// v0.22.7: Login/splash page fields // v0.22.7: Login/splash page fields
InstanceName string // branding: instance display name InstanceName string // branding: instance display name
LogoURL string // branding: custom logo URL LogoURL string // branding: custom logo URL
@@ -110,6 +113,52 @@ func (pd PageData) SurfaceEnabled(id string) bool {
return false 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. // UserContext is the authenticated user's info available to templates.
type UserContext struct { type UserContext struct {
ID string `json:"id"` ID string `json:"id"`
@@ -303,6 +352,68 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
Data: data, Data: data,
Manifest: e.GetSurface(surfaceID), Manifest: e.GetSurface(surfaceID),
EnabledSurfaces: e.EnabledSurfaceIDs(), 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) handler := e.RenderSurface(s.ID)
registerRoutes(group, s, handler) 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 // Admin surfaces — auth + admin role middleware

View File

@@ -22,8 +22,13 @@
<link rel="stylesheet" href="{{.BasePath}}/css/tool-grants.css?v={{.Version}}"> <link rel="stylesheet" href="{{.BasePath}}/css/tool-grants.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/workflow.css?v={{.Version}}"> <link rel="stylesheet" href="{{.BasePath}}/css/workflow.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/admin-surfaces.css?v={{.Version}}"> <link rel="stylesheet" href="{{.BasePath}}/css/admin-surfaces.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/extension-surface.css?v={{.Version}}">
{{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}} {{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}}
{{if eq .Surface "editor"}}{{template "css-editor" .}}{{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")}}
<link rel="stylesheet" href="{{.BasePath}}/surfaces/{{.Surface}}/css/main.css?v={{.Version}}">
{{end}}
<style> <style>
:root { :root {
--banner-h: 28px; --banner-h: 28px;
@@ -63,6 +68,7 @@
{{else if eq .Surface "editor"}}{{template "surface-editor" .}} {{else if eq .Surface "editor"}}{{template "surface-editor" .}}
{{else if eq .Surface "notes"}}{{template "surface-notes" .}} {{else if eq .Surface "notes"}}{{template "surface-notes" .}}
{{else if eq .Surface "settings"}}{{template "surface-settings" .}} {{else if eq .Surface "settings"}}{{template "surface-settings" .}}
{{else if and .Manifest (eq .Manifest.Source "extension")}}{{template "surface-extension" .}}
{{else}}<div style="padding:20px">Unknown surface: {{.Surface}}</div> {{else}}<div style="padding:20px">Unknown surface: {{.Surface}}</div>
{{end}} {{end}}
</div> </div>
@@ -120,6 +126,10 @@
{{if eq .Surface "editor"}}{{template "scripts-editor" .}}{{end}} {{if eq .Surface "editor"}}{{template "scripts-editor" .}}{{end}}
{{if eq .Surface "notes"}}{{template "scripts-notes" .}}{{end}} {{if eq .Surface "notes"}}{{template "scripts-notes" .}}{{end}}
{{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}} {{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}}
{{/* v0.27.0: Extension surface JS — loaded from /surfaces/{id}/js/main.js */}}
{{if and .Manifest (eq .Manifest.Source "extension")}}
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>
{{end}}
{{/* ── Debug Log Modal (all surfaces) ───── */}} {{/* ── Debug Log Modal (all surfaces) ───── */}}
<div id="debugModal" class="modal-overlay"> <div id="debugModal" class="modal-overlay">

View File

@@ -189,6 +189,13 @@ window.addEventListener('unhandledrejection', function(e) {
<span class="sb-label">Editor</span> <span class="sb-label">Editor</span>
</a> </a>
{{end}} {{end}}
{{/* v0.27.0: Extension surface nav items */}}
{{range .ExtensionSurfaces}}
<a href="{{$.BasePath}}{{.Route}}" class="sb-btn sidebar-extension-btn" title="{{.Title}}" data-surface-id="{{.ID}}">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
<span class="sb-label">{{.Title}}</span>
</a>
{{end}}
<div class="sidebar-bottom-divider"></div> <div class="sidebar-bottom-divider"></div>
{{template "user-menu" dict "ID" ""}} {{template "user-menu" dict "ID" ""}}
</div> </div>

View File

@@ -0,0 +1,21 @@
{{/* v0.27.0: Generic container for extension surfaces.
Extension JS mounts into #extension-mount. The manifest is available
as window.__MANIFEST__ (injected in base.html). Platform primitives
(Theme, UI, API, ChatPane, etc.) are loaded by base.html before this
script runs.
Extensions can use any component available on the page:
- ChatPane.create(container, opts)
- UI.* primitives (toast, confirm, etc.)
- API.* (authenticated fetch)
- Theme.* (dark/light queries)
*/}}
{{define "surface-extension"}}
<div id="extension-surface" class="extension-surface"
data-surface-id="{{.Surface}}">
{{/* User menu — must pass dict with ID field, not raw PageData */}}
{{template "user-menu" dict "ID" "ext"}}
<div id="extension-mount" class="extension-mount"></div>
</div>
{{end}}

View File

@@ -0,0 +1,26 @@
/* v0.27.0: Extension surface container styles.
Provides the mount point layout that extension JS renders into.
Extension-specific CSS is loaded from /surfaces/{id}/css/main.css. */
.extension-surface {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.extension-mount {
flex: 1;
min-height: 0;
overflow: auto;
padding: 0;
position: relative;
}
/* Extension surfaces get the same user-menu positioning as other surfaces */
.extension-surface .user-menu-container {
position: absolute;
top: 8px;
right: 8px;
z-index: 100;
}