Changeset 0.27.0 (#166)
This commit is contained in:
@@ -78,6 +78,10 @@ $(page_proxy_block "${BASE_PATH}/editor")
|
||||
$(page_proxy_block "${BASE_PATH}/notes")
|
||||
$(page_proxy_block "${BASE_PATH}/settings")
|
||||
$(page_proxy_block "${BASE_PATH}/w")
|
||||
# v0.27.0: Extension surface page routes
|
||||
$(page_proxy_block "${BASE_PATH}/s")
|
||||
# v0.27.0: Extension surface static assets (served by backend from /data/surfaces/)
|
||||
$(page_proxy_block "${BASE_PATH}/surfaces")
|
||||
ROUTES
|
||||
)
|
||||
|
||||
|
||||
842
docs/DESIGN-0.27.0.md
Normal file
842
docs/DESIGN-0.27.0.md
Normal 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:928–949` — second-pass allowlist via `GetToolGrants()` | v0.25.0, v0.26.0 |
|
||||
| Version snapshot includes persona tool grants | `workflows.go:263–273` — `stageSnapshot.ToolGrants` populated at publish | v0.25.0 |
|
||||
| Session cleanup job | Shipped in v0.26.0 (background goroutine, `SESSION_EXPIRY_DAYS`) | v0.24.3 |
|
||||
|
||||
**Action:** Cross off all three in the ROADMAP's deferred sections and in
|
||||
the v0.25.0 / v0.26.0 milestone checklists.
|
||||
|
||||
---
|
||||
|
||||
## v0.27.0 — Debt Clearance
|
||||
|
||||
All deferred items from v0.25.0 and v0.26.0 plus outstanding tech debt
|
||||
that blocks future work. No new features — just finishing what's owed.
|
||||
|
||||
### Phase 1: Extension Surface Routes (`/s/:slug`)
|
||||
|
||||
The longest-deferred item (originally v0.21.3). Infrastructure exists:
|
||||
`surface_registry` table, `InstallSurface` handler (zip upload + asset
|
||||
extraction), `ListEnabledSurfaces` API. What's missing is the runtime
|
||||
route mounting, template rendering, and frontend nav integration.
|
||||
|
||||
**Problem: Hardcoded surface conditionals in `base.html`**
|
||||
|
||||
Lines 61–66 and 118–122 of `base.html` use `{{if eq .Surface "chat"}}`
|
||||
chains. Extension surfaces fall through to "Unknown surface". Same
|
||||
pattern for CSS includes (line 25–26) and script includes (118–122).
|
||||
|
||||
**Solution: Generic extension surface template + dynamic blocks**
|
||||
|
||||
```
|
||||
base.html changes:
|
||||
{{if eq .Surface "chat"}}...
|
||||
{{else if eq .Surface "admin"}}...
|
||||
...
|
||||
{{else if .Manifest}}{{template "surface-extension" .}}
|
||||
{{else}}<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 3–5 optional starter templates in v0.27.4.
|
||||
Presented as suggestions during task creation ("Start from a
|
||||
template..." option alongside blank task). Not auto-created — user
|
||||
must explicitly choose one. Templates are just pre-filled form values,
|
||||
not persistent DB records.
|
||||
|
||||
4. **Task notifications.** Opt-in per task. Two booleans on the `tasks`
|
||||
table: `notify_on_complete` (default false), `notify_on_failure`
|
||||
(default true). Uses existing notification infrastructure (v0.20.0
|
||||
bell + WebSocket push).
|
||||
453
docs/EXTENSION-SURFACES.md
Normal file
453
docs/EXTENSION-SURFACES.md
Normal 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
|
||||
@@ -298,137 +298,43 @@ tools. The editor extension defines `read_file`, `write_file`,
|
||||
|
||||
---
|
||||
|
||||
## 6. Surfaces (Modes)
|
||||
## 6. Extension Surfaces
|
||||
|
||||
*Implemented in v0.21.3.*
|
||||
*Shipped in v0.27.0.*
|
||||
|
||||
A "mode" is an extension that registers a **surface** — a UI region that
|
||||
replaces or augments the default chat area. The surface system preserves
|
||||
DOM state across mode switches (critical for CM6 editor instances).
|
||||
Extension surfaces are full-page custom UIs installed as `.surface`
|
||||
archives and served at `/s/:slug`. They run inside the platform shell
|
||||
with access to the authenticated API, theme system, and UI components.
|
||||
|
||||
### 6.1 Surface Regions
|
||||
See **[EXTENSION-SURFACES.md](EXTENSION-SURFACES.md)** for the complete
|
||||
authoring guide including manifest format, platform API reference, CSS
|
||||
custom properties, and a worked example.
|
||||
|
||||
The following `data-surface-region` attributes are present in `index.html`:
|
||||
### Summary
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ sidebar-top │ surface-header │
|
||||
│ │ (model-bar) │
|
||||
│ mode-selector │ │
|
||||
│ (auto-shown │ surface-main │
|
||||
│ when ≥2 │ (chatMessages) │
|
||||
│ surfaces) │ │
|
||||
│ │ │
|
||||
│ sidebar-content │ surface-footer │
|
||||
│ (search+chats) │ (input-area) │
|
||||
│ │ │
|
||||
│ sidebar-bottom │ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
- **Archive format:** zip with `manifest.json` + `js/main.js` + optional `css/main.css`
|
||||
- **Install:** Admin panel → Surfaces → Upload (no restart required)
|
||||
- **Route:** `/s/{id}` — rendered via Go templates, DB lookup at request time
|
||||
- **Entry point:** `js/main.js` mounts into `#extension-mount`
|
||||
- **Platform access:** `API._get()`, `UI.toast()`, `Theme.resolved()`, `Events.on()`, `ChatPane.create()`
|
||||
- **Theming:** CSS custom properties from `variables.css` (`--bg`, `--bg-surface`, `--text`, `--text-2`, `--accent`, etc.)
|
||||
- **Admin API:** install, enable/disable, uninstall via `/api/v1/admin/surfaces/*`
|
||||
|
||||
### 6.2 Surface Registration
|
||||
### Relationship to Other Extension Types
|
||||
|
||||
**Manifest-based** (planned — extension loader integration):
|
||||
Extension surfaces are the **most capable** extension type. They create
|
||||
entirely new pages. Other extension types are lighter-weight:
|
||||
|
||||
```json
|
||||
{
|
||||
"surfaces": [
|
||||
{
|
||||
"id": "editor",
|
||||
"label": "Editor",
|
||||
"icon": "code",
|
||||
"regions": ["surface-main", "surface-footer", "sidebar-content"],
|
||||
"default": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
| Type | Creates a page? | Uses |
|
||||
|------|----------------|------|
|
||||
| **Surface** (`/s/:slug`) | Yes — full page | Custom dashboards, tools, visualizations |
|
||||
| **Block renderer** (Hook 3) | No — transforms message content | Mermaid diagrams, KaTeX math, CSV tables |
|
||||
| **Tool** (Hook 5) | No — LLM-invocable function | Calculator, web search, file operations |
|
||||
| **Post-render** (Hook 3) | No — enhances rendered messages | Syntax highlighting, link previews |
|
||||
|
||||
**Imperative** (implemented):
|
||||
|
||||
```js
|
||||
Extensions.register({
|
||||
id: 'editor-mode',
|
||||
init(ctx) {
|
||||
ctx.surfaces.register('editor', {
|
||||
label: 'Editor',
|
||||
icon: 'code',
|
||||
regions: ['surface-main', 'surface-footer', 'sidebar-content'],
|
||||
activate() {
|
||||
ctx.ui.replace('surface-main', this.renderEditor());
|
||||
ctx.ui.replace('surface-footer', this.renderEditorInput());
|
||||
ctx.ui.replace('sidebar-content', this.renderFileTree());
|
||||
},
|
||||
deactivate() {
|
||||
ctx.ui.restore('surface-main');
|
||||
ctx.ui.restore('surface-footer');
|
||||
ctx.ui.restore('sidebar-content');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Region management API** (on `ctx.ui`):
|
||||
|
||||
- `ctx.ui.replace(regionId, element)` — saves current children to a
|
||||
`DocumentFragment` (detached, not destroyed), inserts new element.
|
||||
- `ctx.ui.restore(regionId)` — re-attaches saved children.
|
||||
|
||||
This is critical for CM6 — editor instances survive mode switches because
|
||||
their DOM nodes are detached (preserving internal state) rather than removed.
|
||||
|
||||
**Surface management API** (on `ctx.surfaces`):
|
||||
|
||||
- `ctx.surfaces.register(id, opts)` — register a new surface
|
||||
- `ctx.surfaces.unregister(id)` — unregister (switches to chat if active)
|
||||
- `ctx.surfaces.activate(id)` — switch to a surface
|
||||
- `ctx.surfaces.getCurrent()` — returns active surface id
|
||||
|
||||
### 6.3 Mode Selector
|
||||
|
||||
When extensions register surfaces, a mode selector appears in the sidebar
|
||||
(`#modeSelectorWrap`) below the brand/new-chat area. Clicking a mode calls
|
||||
`activate()` on that surface and `deactivate()` on the current one.
|
||||
|
||||
Events fired:
|
||||
```js
|
||||
Events.on('surface.activated', ({ surface, previous }) => { ... });
|
||||
Events.on('surface.deactivated', ({ surface }) => { ... });
|
||||
Events.on('surface.registered', ({ surface }) => { ... });
|
||||
Events.on('surface.unregistered', ({ surface }) => { ... });
|
||||
```
|
||||
|
||||
### 6.4 Core Surface
|
||||
|
||||
Chat mode is the default surface. It's not special architecturally — it's
|
||||
the surface that's active when no extension surface is selected. Pragmatically
|
||||
it stays in core because everything depends on it.
|
||||
|
||||
### 6.5 Planned Modes
|
||||
|
||||
**Editor Mode** (extension)
|
||||
- File tree in sidebar, code editor in main, AI chat in split/overlay.
|
||||
- Tools: `read_file`, `write_file`, `search_replace`, `git_status`,
|
||||
`git_commit` — browser tools backed by a git provider API.
|
||||
- This is ai-editor rebuilt properly: tool calls are server-side, logged,
|
||||
token-counted, auditable.
|
||||
|
||||
**Article Mode** (extension)
|
||||
- Outline in sidebar, rich text editor in main, AI assistant in panel.
|
||||
- Tools: `fetch_url`, `summarize_section`, `check_citation`.
|
||||
- The conversation is hidden; only the document matters.
|
||||
|
||||
**Cluster Manager Mode** (2-3 cooperating extensions)
|
||||
- `k8s-manager`: kubectl tools, pod/deployment views.
|
||||
- `ceph-manager`: ceph status, OSD management.
|
||||
- `node-manager`: SSH commands, system metrics.
|
||||
- These talk to each other via EventBus. When `k8s-manager` detects a
|
||||
node is NotReady, it publishes `cluster.node.unhealthy`. `ceph-manager`
|
||||
subscribes and checks OSD placement.
|
||||
- The LLM sees ALL tools from ALL active cluster extensions. "Drain node-3,
|
||||
ensure Ceph rebalances, then cordon it" → `kubectl_drain`, `ceph_osd_out`,
|
||||
`kubectl_cordon` — each routed to the appropriate extension.
|
||||
A single extension can combine multiple types — e.g. a surface for
|
||||
configuration plus a tool for LLM interaction plus a renderer for
|
||||
custom output formatting.
|
||||
|
||||
---
|
||||
|
||||
|
||||
209
docs/ROADMAP.md
209
docs/ROADMAP.md
@@ -4,6 +4,8 @@
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — Core services design, store layer, scope model
|
||||
- [EXTENSIONS.md](EXTENSIONS.md) — Extension system spec (Browser/Starlark/Sidecar tiers,
|
||||
manifests, browser tool bridge, surfaces/modes, model roles)
|
||||
- [EXTENSION-SURFACES.md](EXTENSION-SURFACES.md) — Extension surface authoring guide
|
||||
(manifest format, platform API, CSS properties, install workflow)
|
||||
- [CHANGELOG.md](../CHANGELOG.md) — Detailed release notes for all completed versions
|
||||
|
||||
**Versioning (pre-1.0):** `0.<major>.<minor>` — hotfixes use quad: `0.x.y.z`
|
||||
@@ -152,9 +154,29 @@ v0.26.0 Workflow Engine ✅
|
||||
human + AI collab, visitor
|
||||
intake, assignment queue)
|
||||
│
|
||||
v0.27.0 Tasks / Autonomous Agents
|
||||
v0.27.0 Debt Clearance
|
||||
(extension surface routes,
|
||||
workflow polish, editor debt)
|
||||
│
|
||||
v0.27.1 Tasks Foundation
|
||||
(service channels, scheduler,
|
||||
unattended execution)
|
||||
task definitions, cron)
|
||||
│
|
||||
v0.27.2 Task Execution
|
||||
(budgets, admin controls,
|
||||
run history, kill switch)
|
||||
│
|
||||
v0.27.3 Task Chaining
|
||||
(webhooks, task_create tool,
|
||||
workflow-to-workflow)
|
||||
│
|
||||
v0.27.4 Personal Tasks
|
||||
(BYOK scheduling, user task UI,
|
||||
starter templates)
|
||||
│
|
||||
v0.28.0 Platform Polish
|
||||
(virtual scroll, KB auto-inject,
|
||||
Helm chart, provider model prefs)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -427,28 +449,28 @@ Organized by domain. Pull into a version when the surrounding work
|
||||
makes them a natural addition.
|
||||
|
||||
**Surfaces + Editor**
|
||||
- [ ] Extension loader: surfaces from manifest.json (was v0.21.3 — `/s/:slug` route deferred to v0.27.0)
|
||||
- [ ] Editor drag-drop file reorder (was v0.21.5)
|
||||
- [x] ~~Extension loader: surfaces from manifest.json~~ (v0.27.0 — `/s/:slug` route)
|
||||
- [x] ~~Editor drag-drop file reorder~~ (v0.27.0 — workspace debt)
|
||||
- [ ] Article drag-to-reorder outline sections (was v0.21.6)
|
||||
- [ ] Article-specific AI tools: suggest_outline, expand_section, check_citations (was v0.21.6)
|
||||
- [x] ~~PDF export via pandoc~~ (shipped v0.22.4)
|
||||
- [ ] Mobile: mode selector collapses to hamburger/bottom nav (was v0.21.3)
|
||||
- [ ] Pane state persistence per-user/per-project (pane positions saved, not yet persisted across sessions)
|
||||
- [x] ~~Pane state persistence per-user/per-project~~ (v0.27.0 — workspace debt)
|
||||
|
||||
**Workspace + Git**
|
||||
- [ ] Workspace settings UI: git config section in channel/project settings (was v0.21.4)
|
||||
- [ ] User settings: git credentials management UI (was v0.21.4)
|
||||
- [ ] `.gitignore` respect in workspace indexing (was v0.21.4)
|
||||
- [x] ~~Workspace settings UI: git config section in channel/project settings~~ (v0.27.0 — workspace debt)
|
||||
- [ ] User settings: git credentials management UI (was v0.21.4 — needs vault extension, v0.28.0+)
|
||||
- [x] ~~`.gitignore` respect in workspace indexing~~ (v0.27.0 — workspace debt)
|
||||
- [ ] Integration tests: clone, commit, push/pull cycle — requires git binary in CI (was v0.21.4)
|
||||
|
||||
**Provider Health + Routing**
|
||||
- [x] ~~`RecordOutcome` for web_search and url_fetch~~ (shipped v0.22.4 — tool health tracking)
|
||||
- [x] ~~Rate limit tracking per provider config~~ (shipped v0.22.4 — `rate_limit_count` on `provider_health`)
|
||||
- [x] ~~Auto-disable: mark provider inactive when `down` for N consecutive health windows~~ (shipped v0.22.4)
|
||||
- [ ] `capability_match` policy type ("cheapest model with tool_calling")
|
||||
- [ ] `capability_match` policy type ("cheapest model with tool_calling") — v0.28.0 candidate
|
||||
- [ ] Latency-aware routing: track response time percentiles, prefer faster providers
|
||||
- [ ] Cost-aware routing with budget ceiling per request
|
||||
- [ ] New provider types registrable via config file (OpenAI-compatible + custom schema)
|
||||
- [ ] New provider types registrable via config file (OpenAI-compatible + custom schema) — v0.28.0 candidate
|
||||
- [ ] Provider profile editor: key-value config per provider type, preview of effective settings
|
||||
- [ ] Fallback chain visualizer: drag-to-reorder provider priority per model family
|
||||
|
||||
@@ -456,7 +478,8 @@ makes them a natural addition.
|
||||
- [x] ~~Session cleanup job~~ (shipped v0.26.0 — background goroutine, `SESSION_EXPIRY_DAYS`)
|
||||
|
||||
**Tool System**
|
||||
- [x] ~~Persona tool grant enforcement~~ (shipped v0.25.0 — second-pass allowlist in completion handler)
|
||||
- [x] ~~Persona tool grant enforcement~~ (shipped v0.25.0 — second-pass allowlist in `completion.go:928`)
|
||||
- [x] ~~Workflow version snapshot includes tool grants~~ (shipped v0.26.0 — `workflows.go:263`)
|
||||
|
||||
|
||||
---
|
||||
@@ -739,7 +762,7 @@ See [DESIGN-0.25.0.md](DESIGN-0.25.0.md) for full spec.
|
||||
- [x] Core surfaces (chat, admin, settings, editor, notes) on manifest pattern
|
||||
- [x] `window.__PAGE_DATA__` injection from declared `data_requires`
|
||||
- [x] `surface_registry` table — admin can enable/disable surfaces (Migration 022)
|
||||
- [ ] `/s/:slug` route namespace for extension/dynamic surfaces — deferred
|
||||
- [ ] `/s/:slug` route namespace for extension/dynamic surfaces — v0.27.0
|
||||
|
||||
**Component Extraction** ✅
|
||||
- [x] FileTree: extracted from `editor-mode.js`, standalone component with Go template partial + JS hydration
|
||||
@@ -767,11 +790,11 @@ See [DESIGN-0.25.0.md](DESIGN-0.25.0.md) for full spec.
|
||||
**CSS Decomposition** ✅
|
||||
- [x] Monolithic `styles.css` → 13 files: variables, layout, primitives, modals, chat, panels, surfaces, splash, pane-container, chat-pane, user-menu, tool-grants, admin-surfaces
|
||||
|
||||
**Persona Tool Grants UI** ✅ (partial — UI only, backend wire deferred)
|
||||
**Persona Tool Grants UI** ✅
|
||||
- [x] Persona create/edit UI: "Tools" section — multi-select checklist grouped by category
|
||||
- [x] `loadToolList()` fetches from `/api/v1/tools`, `loadToolGrants()` reads per-persona grants
|
||||
- [ ] Completion handler applies `GetToolGrants()` as second-pass allowlist — deferred to v0.27.0
|
||||
- [ ] Version snapshot includes persona tool grants at snapshot time — deferred to v0.27.0
|
||||
- [x] Completion handler applies `GetToolGrants()` as second-pass allowlist (`completion.go:928`)
|
||||
- [x] Version snapshot includes persona tool grants at snapshot time (`workflows.go:263`)
|
||||
|
||||
**Bug Fixes (v0.25.3–v0.25.4)**
|
||||
- [x] Theme system mode (system preference → dark fallback)
|
||||
@@ -792,16 +815,19 @@ See [DESIGN-0.25.0.md](DESIGN-0.25.0.md) for full spec.
|
||||
|
||||
**Shared Surface Routes** — partially shipped
|
||||
- [x] `/admin/:section`, `/settings/:section` — full-page surface routes
|
||||
- [ ] `/s/editor/:wsId`, `/s/article/:wsId/:path` — deferred
|
||||
- [ ] `/p/:id` — shared project view — deferred
|
||||
- [ ] `/s/editor/:wsId`, `/s/article/:wsId/:path` — v0.27.0 (extension surface routes)
|
||||
- [ ] `/p/:id` — shared project view — TBD
|
||||
|
||||
**Shipped in v0.26.0:**
|
||||
- [x] Context-Aware Tool System (`ToolContext`, `Require` predicates, `AvailableFor()`)
|
||||
- [x] `ExecutionContext` extension (`WorkflowID`, `TeamID` fields)
|
||||
|
||||
**Deferred to v0.27.0:**
|
||||
- [ ] Tool grant enforcement in completion handler
|
||||
- [ ] Extension surface route namespace (`/s/:slug`)
|
||||
**Shipped (verified in code audit):**
|
||||
- [x] Tool grant enforcement in completion handler (`completion.go:928`)
|
||||
- [x] Workflow version snapshot includes tool grants (`workflows.go:263`)
|
||||
|
||||
**Moved to v0.27.0:**
|
||||
- [x] ~~Extension surface route namespace (`/s/:slug`)~~ → v0.27.0 Phase 1
|
||||
|
||||
**Migration:**
|
||||
- [x] 022_surfaces.sql: `surface_registry` table (UUID PK, unique surface_id, is_enabled, is_core)
|
||||
@@ -836,7 +862,7 @@ release notes.
|
||||
- [x] Route registration test (`TestRouteRegistration`)
|
||||
- [x] Workflow CRUD test (`TestWorkflowCRUD`, `TestWorkflowValidation`)
|
||||
|
||||
**Deferred to v0.27.0:**
|
||||
**Moved to v0.27.0 Phase 2 (Workflow Polish):**
|
||||
- [ ] Team-scoped workflow management UI (team admin settings surface)
|
||||
- [ ] Drag-and-drop stage reorder in builder (backend supports it, UI is manual)
|
||||
- [ ] `on_complete` workflow chaining (column exists nullable, not wired)
|
||||
@@ -844,26 +870,129 @@ release notes.
|
||||
- [ ] Stage persona auto-switch in chat UI
|
||||
- [ ] Round-robin auto-assignment
|
||||
- [ ] Assignment notifications via WebSocket
|
||||
- [ ] Extension surface routes (`/s/:slug` namespace)
|
||||
- [ ] Persona tool grant enforcement in completion handler
|
||||
- [ ] Channel header stage indicator + advance/reject controls
|
||||
|
||||
**Moved to v0.27.0 Phase 1:**
|
||||
- [ ] Extension surface routes (`/s/:slug` namespace)
|
||||
|
||||
**Shipped (verified in code audit):**
|
||||
- [x] Persona tool grant enforcement in completion handler (`completion.go:928`)
|
||||
|
||||
---
|
||||
|
||||
## v0.27.0 — Tasks / Autonomous Agents
|
||||
## v0.27.0 — Debt Clearance
|
||||
|
||||
Unattended execution — workflows without a human in the loop.
|
||||
Depends on: workflow engine (v0.26.0).
|
||||
Extension surface routes, workflow engine polish, workspace/editor debt.
|
||||
Depends on: workflow engine (v0.26.0), dynamic surfaces (v0.25.0).
|
||||
|
||||
_(Shifted from v0.23.0 — no content changes, dependency refs updated to v0.26.0)_
|
||||
See [DESIGN-0.27.0.md](DESIGN-0.27.0.md) for full spec.
|
||||
|
||||
- [ ] Scheduler + task runner (cron-like triggers for workflow instantiation)
|
||||
- [ ] `task_create` tool (AI can spawn sub-workflows)
|
||||
- [ ] `type: 'service'` channels: workflow instances with no human participants
|
||||
- [ ] Execution budgets: max tokens, max tool calls, max wall-clock per stage
|
||||
- [ ] Admin controls for resource limits and kill switches
|
||||
- [ ] Completion webhooks (notify external systems when workflow completes)
|
||||
- [ ] Workflow chaining: one workflow's completion triggers another workflow's start
|
||||
**Phase 1: Extension Surface Routes (`/s/:slug`)**
|
||||
- [ ] `surface-extension` Go template (HTML + CSS + scripts blocks)
|
||||
- [ ] `base.html` conditional chain extended with `{{if .Manifest}}` fallthrough
|
||||
- [ ] `loadExtensionSurfaces()` in page engine (reads extension manifests from DB at startup)
|
||||
- [ ] nginx location block for `/surfaces/` static assets
|
||||
- [ ] Frontend nav renders extension surfaces from `ListEnabledSurfaces`
|
||||
- [ ] Admin surfaces section: upload/enable/disable/uninstall (API already exists)
|
||||
- [ ] Sample `.surface` archive (hello-world dashboard) for testing
|
||||
- [ ] CSP nonce propagation for extension scripts
|
||||
|
||||
**Phase 2: Workflow Engine Polish (v0.26.0 debt)**
|
||||
- [ ] Channel header workflow stage indicator + advance/reject controls
|
||||
- [ ] Stage persona auto-switch in chat UI
|
||||
- [ ] Assignment notifications via WebSocket (`workflow.assigned`, `workflow.claimed`)
|
||||
- [ ] Round-robin auto-assignment (per-stage `transition_rules.auto_assign`)
|
||||
- [ ] `on_complete` workflow chaining (column exists, wire trigger logic)
|
||||
- [ ] Workflow retention enforcement (extend staleness sweep goroutine)
|
||||
- [ ] Drag-and-drop stage reorder in workflow builder UI
|
||||
- [ ] Team-scoped workflow management UI (team settings → Workflows section)
|
||||
|
||||
**Phase 3: Workspace + Editor Debt**
|
||||
- [ ] `.gitignore` respect in workspace indexing (`workspace/indexer.go`)
|
||||
- [ ] Workspace settings UI: git config section in channel/project settings
|
||||
- [ ] Pane state persistence per-user/per-project (save/restore in `user_settings`)
|
||||
- [ ] Editor drag-drop file reorder (workspace file tree DnD)
|
||||
|
||||
---
|
||||
|
||||
## v0.27.1 — Tasks Foundation: Service Channels + Scheduler
|
||||
|
||||
Core primitive for autonomous agents: a channel with no human
|
||||
participant, driven by a cron scheduler.
|
||||
Depends on: v0.27.0 (debt clearance).
|
||||
|
||||
- [ ] `tasks` table + store interface + PG/SQLite implementations
|
||||
- [ ] `type: 'service'` channel type (no human participants)
|
||||
- [ ] `TaskScheduler` background goroutine (poll interval: 30s)
|
||||
- [ ] Cron expression parsing (`robfig/cron/v3`) + `next_run_at` computation
|
||||
- [ ] Prompt task execution (create service channel, send prompt, collect response)
|
||||
- [ ] Workflow task execution (instantiate workflow in service channel)
|
||||
- [ ] Provider resolution for task context (BYOK → team → global → routing policy)
|
||||
- [ ] Tasks sidebar section (read-only view of service channels)
|
||||
- [ ] Migration 026: `tasks` table, `service` channel type
|
||||
|
||||
---
|
||||
|
||||
## v0.27.2 — Task Execution: Budgets + Admin Controls
|
||||
|
||||
Guardrails for unattended execution.
|
||||
Depends on: v0.27.1 (task scheduler).
|
||||
|
||||
- [ ] Execution budget enforcement: `max_tokens`, `max_tool_calls`, `max_wall_clock`
|
||||
- [ ] `task_runs` table + store (run history, status, budget usage)
|
||||
- [ ] Budget breach → `budget_exceeded` status + owner notification
|
||||
- [ ] Concurrent run skip (if previous run still active, skip with warning)
|
||||
- [ ] Global config: `tasks.enabled`, `tasks.allow_personal`, `tasks.max_concurrent`
|
||||
- [ ] Default budget ceilings in global config (overridable per task)
|
||||
- [ ] `tasks.create` and `tasks.admin` permissions
|
||||
- [ ] Admin panel → Tasks section (CRUD, history, kill switch, budget config)
|
||||
|
||||
---
|
||||
|
||||
## v0.27.3 — Task Chaining: Webhooks + Workflow-to-Workflow
|
||||
|
||||
External integration and multi-workflow orchestration.
|
||||
Depends on: v0.27.2 (task budgets).
|
||||
|
||||
- [ ] Completion webhooks (POST to external URL on task/workflow completion)
|
||||
- [ ] Webhook retry (3 attempts, exponential backoff) + HMAC signature
|
||||
- [ ] `task_create` tool (AI spawns sub-tasks, `RequireWorkflow` predicate, depth limit)
|
||||
- [ ] `on_complete` chaining via task system (completed workflow → one-shot task → target workflow)
|
||||
- [ ] Webhook secret generation per task/workflow
|
||||
|
||||
---
|
||||
|
||||
## v0.27.4 — Personal Tasks: BYOK Scheduling + User Task UI
|
||||
|
||||
User-facing task experience. Personal tasks run against BYOK providers.
|
||||
Depends on: v0.27.2 (admin controls, `tasks.allow_personal`).
|
||||
|
||||
- [ ] Settings → Tasks section (CRUD, schedule builder, budget config)
|
||||
- [ ] Schedule builder: preset crons + custom 5-field cron + timezone selector
|
||||
- [ ] BYOK provider routing for personal tasks
|
||||
- [ ] `tasks.personal_require_byok` config key (prevent fallthrough to org providers)
|
||||
- [ ] Output modes: channel (default), note, webhook
|
||||
- [ ] Task notification booleans: `notify_on_complete` (default false), `notify_on_failure` (default true)
|
||||
- [ ] Tasks sidebar section with status indicators (✓/✗/⏳/▶)
|
||||
- [ ] "Run Now" button for manual trigger
|
||||
- [ ] 3–5 optional starter templates (news digest, stock screener, standup prep, etc.)
|
||||
|
||||
---
|
||||
|
||||
## v0.28.0 — Platform Polish
|
||||
|
||||
TBD pull-forward: high-value items whose dependencies are met post-tasks.
|
||||
|
||||
**Tier 1 — High value, dependencies met:**
|
||||
- [ ] Virtual scroll for long conversations (prerequisite for heavy task output channels)
|
||||
- [ ] KB auto-injection: top-K chunk prepend, context budget aware, per-channel toggle
|
||||
- [ ] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`)
|
||||
- [ ] Per-provider model preferences (`provider_config_id` dimension in `user_model_settings`)
|
||||
|
||||
**Tier 2 — Medium value:**
|
||||
- [ ] Memory compaction: summarize old memories, confidence decay, prune low-confidence
|
||||
- [ ] `capability_match` routing policy ("cheapest model with tool_calling")
|
||||
- [ ] New provider types registrable via config file (OpenAI-compatible + custom schema)
|
||||
|
||||
---
|
||||
|
||||
@@ -895,7 +1024,7 @@ based on need.
|
||||
|
||||
**UX / Multi-Seat**
|
||||
- "Group" scope badge on model selector / KB list: show access-source annotation so users see _why_ they have access (global, team, group grant). Requires backend to include `access_source` in list responses.
|
||||
- Per-provider model preferences: `user_model_settings` unique key is `(user_id, model_id)` — same model from different providers shares one visibility toggle. Needs `provider_config_id` dimension in DB constraint, store, API, and frontend `hiddenModels` keying. Frontend composite key (`configId:modelId`) already exists in `App.models[].id`.
|
||||
- ~~Per-provider model preferences~~ → scheduled v0.28.0. `user_model_settings` unique key is `(user_id, model_id)` — same model from different providers shares one visibility toggle. Needs `provider_config_id` dimension in DB constraint, store, API, and frontend `hiddenModels` keying. Frontend composite key (`configId:modelId`) already exists in `App.models[].id`.
|
||||
- Git credentials store: vault-encrypted per-user git credentials (similar to BYOK pattern). Git provider abstraction (GitHub, GitLab, Gitea). Clone/pull/push handlers. Natural fit for extension sidecar tier since git operations are long-running.
|
||||
- Admin settings team/user export: v0.25.4 ships admin-only settings export/import. User export blocked by vault-encrypted BYOK keys (can't round-trip). Team export needs merge-vs-replace semantics for member lists.
|
||||
|
||||
@@ -907,7 +1036,7 @@ based on need.
|
||||
- Sub-projects / nested hierarchy: child inherits parent KBs, system prompt, persona (deferred until usage patterns clarify need vs tags/labels)
|
||||
|
||||
**Knowledge Bases — Future**
|
||||
- KB auto-injection: top-K chunk prepend to system prompt, context budget aware, per-channel toggle (latency budgeting required)
|
||||
- ~~KB auto-injection~~ → scheduled v0.28.0
|
||||
- Hybrid search: combine vector similarity with full-text `tsvector`, re-rank
|
||||
- Semantic chunking: embedding-based boundary detection for smarter splits
|
||||
- HNSW index: better query performance than IVFFlat for large datasets
|
||||
@@ -917,8 +1046,8 @@ based on need.
|
||||
(currently uses direct `database.DB.ExecContext` in handler — works but bypasses store layer)
|
||||
|
||||
**Memory — Future**
|
||||
- Memory compaction: summarize old memories to save context tokens
|
||||
- Memory confidence decay: reduce confidence over time, prune low-confidence entries
|
||||
- ~~Memory compaction~~ → scheduled v0.28.0
|
||||
- ~~Memory confidence decay~~ → scheduled v0.28.0 (combined with compaction)
|
||||
- Memory export/import: portable memory format across instances
|
||||
- Cross-persona memory sharing (opt-in): e.g. "coding assistant" can read facts from "project manager"
|
||||
- Memory analytics: dashboard showing what Personas are learning, memory growth trends
|
||||
@@ -928,9 +1057,9 @@ based on need.
|
||||
- ~~Provider health monitoring~~ → v0.22.0 + key rotation
|
||||
- Multi-tenant SaaS mode
|
||||
- Plugin/extension marketplace
|
||||
- Virtual scroll for long conversations
|
||||
- ~~Virtual scroll for long conversations~~ → scheduled v0.28.0
|
||||
- ~~SQLite backend option (single-user / dev)~~ → v0.17.1
|
||||
- **Helm chart.** The k8s/ raw manifests with `${VAR}` substitution have served well but are friction for external adopters and make values management manual. A Helm chart wraps the same backend + frontend deployments with a `values.yaml` (replicas, image tags, ingress host, storage class, secret refs, resource limits, feature flags). Target: `helm install switchboard ./chart` for a fresh cluster, `helm upgrade` for rolling deploys. Subcharts for optional Postgres (for dev/test — prod uses external). Candidate for v0.28+ or a parallel track once the core feature set stabilizes.
|
||||
- ~~**Helm chart.**~~ → scheduled v0.28.0
|
||||
|
||||
~~**Pane Architecture (Workspace Container)**~~ → shipped in v0.25.0
|
||||
|
||||
|
||||
350
docs/SURFACES.md
350
docs/SURFACES.md
@@ -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
BIN
hello-dashboard.surface
Normal file
Binary file not shown.
17
nginx.conf
17
nginx.conf
@@ -73,6 +73,23 @@ server {
|
||||
location /editor { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||
location /notes { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||
location /settings { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||
location /w/ { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||
|
||||
# v0.27.0: Extension surface page routes → backend (Go templates)
|
||||
location /s/ {
|
||||
proxy_pass $backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# v0.27.0: Extension surface static assets (JS, CSS, images)
|
||||
location /surfaces/ {
|
||||
alias /data/surfaces/;
|
||||
expires 1h;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# Fallback: redirect unknown paths to root
|
||||
location / {
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -1049,6 +1051,25 @@ func main() {
|
||||
// Server-rendered pages via Go templates. Runs alongside the SPA.
|
||||
// The chat surface is a bridge: Go renders the shell, existing JS
|
||||
// builds the DOM inside it — identical behavior to index.html.
|
||||
|
||||
// v0.27.0: Extension surface static assets (JS, CSS, images).
|
||||
// Served without auth — same rationale as extension assets (script tags can't send headers).
|
||||
// In split deployment, nginx serves these from /data/surfaces/ directly.
|
||||
if cfg.StoragePath != "" {
|
||||
surfacesStaticDir := cfg.StoragePath + "/surfaces"
|
||||
base.GET("/surfaces/:id/*path", func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
filePath := c.Param("path")
|
||||
// Security: prevent path traversal
|
||||
clean := filepath.Clean(filepath.Join(surfacesStaticDir, id, filePath))
|
||||
if !strings.HasPrefix(clean, filepath.Clean(surfacesStaticDir)) {
|
||||
c.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
c.File(clean)
|
||||
})
|
||||
}
|
||||
|
||||
pages.SetVersion(Version)
|
||||
pageEngine := pages.New(cfg, stores)
|
||||
|
||||
@@ -1105,7 +1126,7 @@ func main() {
|
||||
}
|
||||
log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath)
|
||||
log.Printf(" Health: provider tracking active (flush=%s, prune=%s)", health.FlushInterval, health.PruneAge)
|
||||
log.Printf(" Pages: template engine active (surfaces: chat, editor, notes, settings, admin)")
|
||||
log.Printf(" Pages: template engine active (%d surfaces registered)", len(pageEngine.Surfaces()))
|
||||
if err := r.Run(":" + cfg.Port); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
|
||||
@@ -91,6 +91,9 @@ type PageData struct {
|
||||
// v0.25.0: Enabled surface IDs for conditional nav rendering.
|
||||
EnabledSurfaces []string `json:"-"`
|
||||
|
||||
// v0.27.0: Extension surfaces for sidebar nav rendering.
|
||||
ExtensionSurfaces []ExtensionNavItem `json:"-"`
|
||||
|
||||
// v0.22.7: Login/splash page fields
|
||||
InstanceName string // branding: instance display name
|
||||
LogoURL string // branding: custom logo URL
|
||||
@@ -110,6 +113,52 @@ func (pd PageData) SurfaceEnabled(id string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ExtensionNavItem holds the minimal info needed to render an extension
|
||||
// surface link in the sidebar nav.
|
||||
type ExtensionNavItem struct {
|
||||
ID string
|
||||
Title string
|
||||
Route string
|
||||
}
|
||||
|
||||
// extensionNavItems returns enabled extension surfaces for sidebar rendering.
|
||||
// Queries the DB at render time so newly installed surfaces appear immediately.
|
||||
func (e *Engine) extensionNavItems() []ExtensionNavItem {
|
||||
if e.stores.Surfaces == nil {
|
||||
return nil
|
||||
}
|
||||
ctx := context.Background()
|
||||
surfaces, err := e.stores.Surfaces.List(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[pages] Failed to load extension surfaces for nav: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var items []ExtensionNavItem
|
||||
for _, sr := range surfaces {
|
||||
if sr.Source == "core" || !sr.Enabled {
|
||||
continue
|
||||
}
|
||||
// Editor is Source="extension" but hardcoded as core — skip from dynamic nav
|
||||
if sr.ID == "editor" {
|
||||
continue
|
||||
}
|
||||
route := ""
|
||||
if sr.Manifest != nil {
|
||||
route, _ = sr.Manifest["route"].(string)
|
||||
}
|
||||
if route == "" {
|
||||
route = "/s/" + sr.ID
|
||||
}
|
||||
items = append(items, ExtensionNavItem{
|
||||
ID: sr.ID,
|
||||
Title: sr.Title,
|
||||
Route: route,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// UserContext is the authenticated user's info available to templates.
|
||||
type UserContext struct {
|
||||
ID string `json:"id"`
|
||||
@@ -303,6 +352,68 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
|
||||
Data: data,
|
||||
Manifest: e.GetSurface(surfaceID),
|
||||
EnabledSurfaces: e.EnabledSurfaceIDs(),
|
||||
ExtensionSurfaces: e.extensionNavItems(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RenderExtensionSurface handles /s/:slug — dynamic DB lookup for extension surfaces.
|
||||
// No restart required after installing a surface via admin API.
|
||||
func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
if slug == "" {
|
||||
c.String(http.StatusNotFound, "Surface not found")
|
||||
return
|
||||
}
|
||||
|
||||
if e.stores.Surfaces == nil {
|
||||
c.String(http.StatusNotFound, "Surface registry not available")
|
||||
return
|
||||
}
|
||||
|
||||
// Look up by ID (slug == surface ID)
|
||||
sr, err := e.stores.Surfaces.Get(c.Request.Context(), slug)
|
||||
if err != nil || sr == nil {
|
||||
c.String(http.StatusNotFound, "Surface not found: "+slug)
|
||||
return
|
||||
}
|
||||
if !sr.Enabled {
|
||||
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/")
|
||||
return
|
||||
}
|
||||
if sr.Source == "core" {
|
||||
// Core surfaces have their own routes — don't serve them here
|
||||
c.String(http.StatusNotFound, "Surface not found: "+slug)
|
||||
return
|
||||
}
|
||||
|
||||
user := e.getUserContext(c)
|
||||
|
||||
// Build manifest from DB record
|
||||
route, _ := sr.Manifest["route"].(string)
|
||||
if route == "" {
|
||||
route = "/s/" + sr.ID
|
||||
}
|
||||
layout, _ := sr.Manifest["layout"].(string)
|
||||
if layout == "" {
|
||||
layout = "single"
|
||||
}
|
||||
manifest := &SurfaceManifest{
|
||||
ID: sr.ID,
|
||||
Route: route,
|
||||
Title: sr.Title,
|
||||
Template: "surface-extension",
|
||||
Layout: layout,
|
||||
Source: "extension",
|
||||
}
|
||||
|
||||
e.Render(c, "base.html", PageData{
|
||||
Surface: sr.ID,
|
||||
User: user,
|
||||
Manifest: manifest,
|
||||
EnabledSurfaces: e.EnabledSurfaceIDs(),
|
||||
ExtensionSurfaces: e.extensionNavItems(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -341,6 +452,10 @@ func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddlewar
|
||||
handler := e.RenderSurface(s.ID)
|
||||
registerRoutes(group, s, handler)
|
||||
}
|
||||
|
||||
// v0.27.0: Extension surface catch-all — DB lookup at request time.
|
||||
// No restart needed after installing new surfaces via admin API.
|
||||
group.GET("/s/:slug", e.RenderExtensionSurface())
|
||||
}
|
||||
|
||||
// Admin surfaces — auth + admin role middleware
|
||||
|
||||
@@ -22,8 +22,13 @@
|
||||
<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/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 "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>
|
||||
:root {
|
||||
--banner-h: 28px;
|
||||
@@ -63,6 +68,7 @@
|
||||
{{else if eq .Surface "editor"}}{{template "surface-editor" .}}
|
||||
{{else if eq .Surface "notes"}}{{template "surface-notes" .}}
|
||||
{{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>
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -120,6 +126,10 @@
|
||||
{{if eq .Surface "editor"}}{{template "scripts-editor" .}}{{end}}
|
||||
{{if eq .Surface "notes"}}{{template "scripts-notes" .}}{{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) ───── */}}
|
||||
<div id="debugModal" class="modal-overlay">
|
||||
|
||||
@@ -189,6 +189,13 @@ window.addEventListener('unhandledrejection', function(e) {
|
||||
<span class="sb-label">Editor</span>
|
||||
</a>
|
||||
{{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>
|
||||
{{template "user-menu" dict "ID" ""}}
|
||||
</div>
|
||||
|
||||
21
server/pages/templates/surfaces/extension.html
Normal file
21
server/pages/templates/surfaces/extension.html
Normal 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}}
|
||||
26
src/css/extension-surface.css
Normal file
26
src/css/extension-surface.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user