Changeset 0.36.0 (#212)

This commit is contained in:
2026-03-20 16:08:12 +00:00
parent 668c608b4f
commit 0ab2800c7e
13 changed files with 11598 additions and 1442 deletions

View File

@@ -1,754 +0,0 @@
# DESIGN — v0.28.5: Frontend SDK + Pipe/Filter Pipeline
**Status:** Draft
**Scope:** `switchboard-sdk.js`, pipe/filter pipeline, extension compat shims, ICD `sdk` test tier
**Depends on:** v0.28.4 (security tier), v0.28.3 (frontend decomposition — `sb.js`, IIFE→module, `sb.call()`)
**Informs:** v0.28.7 (unified packaging — manifest `"pipes"` key), v0.29.0+ (Starlark pipeline stages)
**Current version:** 0.28.4
---
## Overview
The platform has 47 JS files, 15+ globals (`API`, `Events`, `UI`, `App`,
`Extensions`, `ChatPane`, `Theme`, etc.), and a partially-decomposed
frontend (Phase 4 ES modules, `sb.register()`/`sb.ns()` action registry).
Surface authors and extension authors must hunt through multiple files,
understand load order, and hold implicit coupling in their heads.
v0.28.5 delivers two things:
1. **`switchboard-sdk.js` (the SDK)** — a single-object composition layer
(`sw`) over existing globals. One import, one init call, coherent API.
Does not rewrite internals; wraps and delegates.
2. **Pipe/filter pipeline** — three composable, priority-ordered transform
stages that formalize the ad-hoc extension hooks into a real pipeline:
pre-send → post-receive (stream) → post-render. Extensions register
filters; the SDK wires them. Replaces `ctx.renderers.register()` and
the implicit hook points in `chat.js` / `ui-core.js` / `ui-format.js`.
---
## Design Principles
**Wrap, don't rewrite.** The SDK delegates to `API`, `Events`, `UI`, `App`,
`Theme`, `Extensions`, `ChatPane`. It does not duplicate their logic. If
`API.streamCompletion()` changes, the SDK's `sw.api.post()` still works
because it calls through. This means v0.28.5 doesn't risk destabilizing
the 531 passing CI tests.
**The pipeline is the extension model.** The existing six hooks in
DESIGN-SURFACES.md (Stream Processing, Post-Render, Tool Bridge, etc.)
are replaced by three pipe stages plus the existing tool bridge. The old
`ctx.renderers.register()` becomes a compat shim that registers into
`sw.pipe.render()`. New extensions use `sw.pipe.*` directly.
**Filters are pure transforms.** A filter receives a context object and
returns it (possibly modified). Side effects are allowed (logging, metrics)
but the contract is input→output. This makes filters testable in isolation.
**Priority ordering is explicit.** Every filter declares a numeric priority.
Lower runs first. Ties break by registration order. The SDK enforces this
and exposes introspection (`sw.pipe.list()`).
**Error isolation is mandatory.** One filter throwing must not break the
chain. The SDK catches, logs, records the failure in telemetry, and
continues with the next filter. A filter can explicitly halt the chain
by returning `null` (pre-send: message suppressed) or a `{ halt: true }`
signal.
---
## SDK API Reference
### Boot
```js
// Idempotent. Safe to call multiple times (returns same instance).
// Typically called by the surface boot script after DOMContentLoaded.
const sw = Switchboard.init({ mount: '#app' });
```
`init()` performs:
1. Token load (`API.loadTokens()`)
2. Profile resolution (`API.getProfile()`)
3. Theme init (`Theme.init()`)
4. EventBus connection (`Events.connect()`)
5. Pipeline engine init (empty filter chains)
6. UserMenu hydration (absorbs cs15 band-aid)
7. Emits `sw:ready` event on `document`
If already initialized, returns the existing `sw` instance. The `mount`
option is reserved for future component auto-mounting but is not used in
v0.28.5 (surfaces manage their own DOM).
### Identity
```js
sw.user // { id, username, display_name, email, role, avatar }
sw.isAdmin // boolean (shorthand for sw.user.role === 'admin')
```
Read-only. Resolved during `init()`. Updates on `profile.updated` event.
Delegates to: `API.user`, `API.isAdmin`.
### REST Client
```js
const channels = await sw.api.get('/api/v1/channels');
const created = await sw.api.post('/api/v1/channels', { title, model });
const updated = await sw.api.put('/api/v1/channels/123', { title });
const deleted = await sw.api.del('/api/v1/channels/123');
// Streaming (returns raw Response for SSE consumption)
const resp = await sw.api.stream('/api/v1/chat/completions', body, signal);
```
All methods auto-prefix `BASE_PATH`, inject auth headers, handle 401
refresh, and parse JSON. `sw.api.stream()` returns the raw `Response`
for SSE reading (same contract as `API.streamCompletion()` but generic).
Delegates to: `API._get()`, `API._post()`, `API._put()`, `API._delete()`,
`API._fetch()`.
### Events
```js
const unsub = sw.on('chat.message.*', (payload, meta) => { ... });
sw.once('workflow.completed', (payload) => { ... });
sw.emit('custom.event', { data: 42 });
sw.off('chat.message.*', handler);
```
Thin passthrough to `Events.on/once/off/emit`. No behavior change.
The SDK does not add any event namespacing — extensions already namespace
by convention (`ext.{id}.{event}`).
Delegates to: `Events`.
### Components
```js
// Drop-in ChatPane — creates the component in the container
const pane = sw.chat(document.getElementById('myChat'), {
channelId: 'abc123',
standalone: true,
});
pane.renderMessages(messages);
pane.destroy();
// Drop-in notes panel
const notes = sw.notes(document.getElementById('myNotes'), {
projectId: 'proj-1',
});
```
`sw.chat()` wraps `ChatPane.create()` with SDK-aware defaults (auto-wires
pipe filters into the pane's stream/render path). `sw.notes()` wraps the
notes panel initialization from `notes.js`.
Delegates to: `ChatPane.create()`, notes init functions.
### UI Primitives
```js
sw.toast('Saved', 'success'); // UI.toast()
const ok = await sw.confirm('Delete?'); // showConfirm()
sw.modal.open(contentEl); // openModal()
sw.modal.close(); // closeModal()
```
Delegates to: `UI.toast()`, `showConfirm()`, `openModal()`, `closeModal()`.
### Theme
```js
sw.theme.current // 'dark' | 'light' (resolved, never 'system')
sw.theme.mode // 'dark' | 'light' | 'system' (user preference)
sw.theme.set('dark'); // Theme.set()
sw.theme.on('change', (resolved) => { ... });
```
`sw.theme.on('change', fn)` subscribes to the `theme.changed` EventBus
event and fires with the resolved theme string.
Delegates to: `Theme.get()`, `Theme.resolved()`, `Theme.set()`, `Events.on('theme.changed', ...)`.
---
## Pipe/Filter Pipeline
### Architecture
```
User types message
┌─────────────────┐
│ PRE-SEND chain │ sw.pipe.pre(priority, fn)
│ filter₁ → ··· → filterₙ
└────────┬────────┘
│ context: { message, channel, attachments, model, persona, metadata }
API.streamCompletion()
▼ SSE chunks
┌─────────────────┐
│ STREAM chain │ sw.pipe.stream(priority, fn)
│ filter₁ → ··· → filterₙ (per chunk)
└────────┬────────┘
│ context: { chunk, accumulated, channel, model, tokens }
formatMessage() → DOMPurify
▼ rendered HTML
┌─────────────────┐
│ RENDER chain │ sw.pipe.render(priority, fn)
│ filter₁ → ··· → filterₙ
└────────┬────────┘
│ context: { element, message, channel }
DOM inserted
```
### Registration API
```js
// Pre-send: transform message before it hits the server
sw.pipe.pre(10, (ctx) => {
// Inject KB context
ctx.metadata.kb_chunks = getRelevantChunks(ctx.message);
return ctx;
});
// Pre-send with scope: only run on workflow channels
sw.pipe.pre(20, (ctx) => {
// Auto-tag workflow messages with stage metadata
ctx.metadata.workflow_stage = ctx.channel.stage;
return ctx;
}, { scope: { channelType: ['workflow'] } });
// Post-receive: transform each SSE chunk during streaming
sw.pipe.stream(50, (ctx) => {
// Token counting
tokenCounter.add(ctx.chunk);
ctx.tokens = tokenCounter.total;
return ctx;
});
// Post-render: transform rendered DOM
sw.pipe.render(50, (ctx) => {
// Replace mermaid code blocks with rendered diagrams
ctx.element.querySelectorAll('pre code.language-mermaid').forEach(el => {
renderMermaid(el);
});
return ctx;
});
// Post-render scoped to DMs only
sw.pipe.render(60, (ctx) => {
// Highlight @mentions differently in DMs
highlightDmMentions(ctx.element);
return ctx;
}, { scope: { channelType: ['dm'] } });
// Introspection
sw.pipe.list();
// → { pre: [{priority, source, scope, avgMs}], stream: [...], render: [...] }
```
### Filter Contract
```typescript
// Type definitions (for documentation — runtime is plain JS)
interface PreSendContext {
message: string; // User's text (mutable)
channel: { id: string; type: string; title: string };
attachments: string[]; // File IDs
model: string; // Selected model ID
persona: string | null; // Persona ID if active
metadata: Record<string, any>; // Extensible metadata bag
regenerate: boolean; // true if this is a regeneration
regenerateMessageId: string | null; // message ID being regenerated
}
interface StreamContext {
chunk: string; // Current SSE delta text
accumulated: string; // Full response so far
channel: { id: string; type: string };
model: string;
event: string; // SSE event type (content, tool_use, etc.)
tokens: { input: number; output: number }; // Running count
}
interface RenderContext {
element: HTMLElement; // The message container DOM node (mutable)
message: { role: string; content: string; id?: string };
channel: { id: string; type: string };
}
// Filter scope — restricts which channels a filter runs on.
// Omit or pass null for "all channels" (default).
interface FilterScope {
channelType?: string[]; // ['direct','dm','group','channel','workflow','service']
}
// Registration options (third argument to sw.pipe.pre/stream/render)
interface FilterOpts {
scope?: FilterScope; // Channel type restriction
source?: string; // Label for introspection (defaults to extension ID or 'platform')
}
// All filters are synchronous. Return context to continue, null to halt.
type PipeFilter<T> = (ctx: T) => T | null;
```
### Halt Semantics
**Pre-send:** Returning `null` suppresses the message entirely. The SDK
calls `UI.toast('Message blocked by extension', 'warning')` and does not
call `API.streamCompletion()`. Use case: content policy filter.
**Stream:** Returning `null` drops the current chunk (not displayed). The
accumulated buffer is unchanged. Use case: real-time content filter that
strips sensitive data from the stream.
**Render:** Returning `null` skips remaining render filters for this
message. The DOM stays as-is after the last successful filter. Unusual
but available for guard-rail extensions.
### Timing Telemetry
The pipeline engine records per-filter execution time (monotonic clock).
Available via `sw.pipe.list()` and emitted on `pipe.stats` EventBus event
every 60 seconds (if any filters ran). Format:
```json
{
"pre": [{ "source": "kb-inject", "priority": 10, "calls": 47, "avgMs": 2.3, "errors": 0 }],
"stream": [{ "source": "token-counter", "priority": 50, "calls": 12840, "avgMs": 0.1, "errors": 0 }],
"render": [{ "source": "mermaid", "priority": 50, "calls": 47, "avgMs": 15.7, "errors": 1 }]
}
```
### Filter Scoping
Filters declare an optional `scope` at registration time. The pipeline
engine checks scope before invoking a filter — scoped-out filters are
skipped with zero overhead (no function call, no timing entry).
```js
// Only run on workflow and service channels
sw.pipe.pre(10, myFilter, { scope: { channelType: ['workflow', 'service'] } });
// Only run on direct (1:1 AI) chats
sw.pipe.stream(50, tokenCounter, { scope: { channelType: ['direct'] } });
// No scope = runs on everything (default)
sw.pipe.render(50, mermaidRenderer);
```
**Channel types** (from the `channels` table `type` column):
`direct`, `dm`, `group`, `channel`, `workflow`, `service`.
**Scope evaluation:** Before each filter call, the engine reads
`ctx.channel.type` and checks against the filter's `scope.channelType`
array. If the type is not in the array, the filter is skipped. If the
filter has no scope (or `scope` is `null`), it always runs.
**Introspection:** `sw.pipe.list()` includes the `scope` object for
each filter so extensions can inspect which filters apply to their
channel type.
**Manifest mapping (v0.28.7):** The `"pipes"` manifest key will support
the same `scope` object:
```json
{
"pipes": [
{
"stage": "pre",
"priority": 10,
"handler": "filters.kbInject",
"scope": { "channelType": ["direct", "dm", "group"] }
}
]
}
```
---
## Integration Points
### Pre-send: `chat.js` → `sendMessage()` + `regenerateMessage()`
**`sendMessage()` — current flow (chat.js line ~659):**
```
text = ChatInput.getValue()
→ build body
→ API.streamCompletion(channelId, text, model, ...)
```
**`sendMessage()` — new flow:**
```
text = ChatInput.getValue()
→ build PreSendContext { message, channel, ..., regenerate: false }
→ sw.pipe._runPre(context) // returns modified context or null
→ if null: toast + return
→ API.streamCompletion(ctx.channel.id, ctx.message, ctx.model, ...)
```
**`regenerateMessage()` — current flow (chat.js line ~835):**
```
messageId from clicked button
→ API.streamRegenerate(channelId, messageId, model, ...)
```
**`regenerateMessage()` — new flow:**
```
messageId from clicked button
→ build PreSendContext { message: '', channel, ..., regenerate: true, regenerateMessageId: messageId }
→ sw.pipe._runPre(context)
→ if null: toast + return
→ API.streamRegenerate(ctx.channel.id, messageId, ctx.model, ...)
```
Both paths share the same filter chain. The `regenerate` flag and
`regenerateMessageId` let filters distinguish — a content policy filter
runs on both, a KB inject filter might skip regeneration (the original
context is already in the conversation), a logging filter records both.
The `sendMessage()` function gains ~10 lines. `regenerateMessage()` gains
~8 lines. Extracted helper: `_buildPreSendContext(opts)` constructs the
context object for both callers.
**Metadata passthrough:** The `metadata` bag on `PreSendContext` maps to
the completion request body. Pre-send filters can inject KB chunks,
persona instructions, or custom fields that the backend completion
handler reads from the request. KB auto-inject (v0.28.6) will be the
first platform-level filter using this mechanism.
### Post-receive (stream): `ui-core.js` → `UI.streamResponse()`
Current flow (ui-core.js line ~1060):
```
SSE reader loop:
parse chunk → extract delta
content += delta
activeContentEl.innerHTML = formatMessage(content)
```
New flow:
```
SSE reader loop:
parse chunk → extract delta
streamCtx = { chunk: delta, accumulated: content, ... }
streamCtx = sw.pipe._runStream(streamCtx)
if streamCtx is null: continue (chunk dropped)
content = streamCtx.accumulated // filter may have mutated
activeContentEl.innerHTML = formatMessage(content)
```
The pipe runs synchronously per chunk (filters that need async should
buffer internally). The `accumulated` field lets filters see the full
response so far (for context-dependent transforms like citation
injection that need to see paragraph boundaries).
**Performance:** Stream filters run on every SSE chunk (potentially
hundreds per response). The telemetry tracks per-filter avgMs. Filters
exceeding 5ms average get a console warning. The pipeline engine skips
disabled filters without allocation.
### Post-render: `ui-format.js` + `ui-core.js`
Current flow:
```
formatMessage(content) // marked.js + DOMPurify
→ Extensions.runBlockRenderers() // inside formatMessage, per code block
→ el.innerHTML = html
→ runExtensionPostRender(el) // Extensions.runPostRenderers()
```
New flow:
```
formatMessage(content) // unchanged
→ el.innerHTML = html
→ renderCtx = { element: el, message, channel }
→ sw.pipe._runRender(renderCtx) // subsumes runExtensionPostRender
```
The render pipe replaces both `Extensions.runBlockRenderers()` and
`Extensions.runPostRenderers()`. The distinction between "block" and
"post" renderer types collapses into a single render stage — filters
operate on the full message DOM and can target code blocks, inline
elements, or the entire container.
**Block renderer compat:** Extensions that register block renderers via
the old `ctx.renderers.register(name, { type: 'block', ... })` API get
automatically shimmed into `sw.pipe.render()` filters. The shim wraps
the old `(lang, code, container)` signature into the new
`(renderCtx) => ...` contract. Priority is preserved.
---
## Backward Compatibility
### Extension Context Shim
The existing `Extensions._buildContext()` returns a `ctx` object with
`ctx.renderers.register()`, `ctx.tools.handle()`, `ctx.events.*`,
`ctx.ui.*`, `ctx.storage.*`, and `ctx.api.*`.
All of these continue to work. The shim layer:
| Old API | Shim behavior |
|---------|--------------|
| `ctx.renderers.register(name, { type: 'block', ... })` | Wraps into `sw.pipe.render()` filter with matching priority |
| `ctx.renderers.register(name, { type: 'post', ... })` | Wraps into `sw.pipe.render()` filter |
| `ctx.renderers.register(name, { type: 'inline', ... })` | Wraps into `sw.pipe.render()` filter (inline → DOM manipulation) |
| `ctx.tools.handle(name, fn)` | Unchanged — tool bridge is orthogonal to pipes |
| `ctx.events.*` | Unchanged — passthrough to `Events` |
| `ctx.ui.*` | Unchanged — passthrough to `UI` / `showConfirm` / `Theme` |
| `ctx.storage.*` | Unchanged — scoped `localStorage` |
| `ctx.api.*` | Unchanged — passthrough to `API._fetch` |
| `ctx.surfaces.*` | Unchanged (already stub/warn for removed features) |
The shim is invisible to existing extensions. They register the same way;
the pipeline engine sees their filters alongside new `sw.pipe.*` filters
and orders everything by priority.
### Global Availability
`sb.js` continues to work. `window.API`, `window.Events`, `window.UI`,
etc. remain available. The SDK is additive — it does not remove globals
or break direct access. Phase 5 (ES module `import`/`export` migration)
is when globals start going away, and the SDK becomes the only entry point.
### Load Order
```
base.html:
sb.js ← action registry (unchanged)
app-state.js ← App, fetchModels (unchanged)
api.js ← API (unchanged)
events.js ← Events (unchanged)
ui-primitives.js ← Theme, showConfirm, etc. (unchanged)
ui-format.js ← formatMessage (patched: render pipe integration)
ui-core.js ← UI (patched: stream pipe integration)
switchboard-sdk.js ← NEW: Switchboard object, pipe engine
extensions.js ← Extensions (patched: renderer shim)
... surface-specific JS ...
chat.js ← sendMessage (patched: pre-send pipe)
app.js ← init (patched: calls Switchboard.init)
```
`switchboard-sdk.js` loads after all primitives/globals it wraps and
before `extensions.js` (so the shim layer is ready when extensions
register renderers). `app.js` `init()` calls `Switchboard.init()` as
part of its boot sequence; the SDK handles idempotency.
---
## File Inventory
### New Files
| File | Purpose |
|------|---------|
| `src/js/switchboard-sdk.js` | SDK object, pipe engine, compat shims |
| `surfaces/icd-test-runner/js/tier-sdk.js` | ICD runner `sdk` test tier |
| `docs/DESIGN-0.28.5.md` | This document (archived after merge) |
### Modified Files
| File | Change |
|------|--------|
| `src/js/chat.js` | `sendMessage()`: pre-send pipe insertion |
| `src/js/ui-core.js` | `streamResponse()`: stream pipe insertion |
| `src/js/ui-format.js` | `runExtensionPostRender()`: delegates to render pipe |
| `src/js/extensions.js` | `_registerRenderer()`: shim into `sw.pipe.render()` |
| `src/js/app.js` | `init()`: call `Switchboard.init()` during boot |
| `src/templates/base.html` | Add `<script>` for `switchboard-sdk.js` |
| `surfaces/icd-test-runner/js/main.js` | Register `sdk` tier |
| `surfaces/icd-test-runner/manifest.json` | Include `tier-sdk.js` |
### No Changes
All other 40+ JS files are untouched. The SDK wraps them; it does not
modify their internals (except the five patched files above).
---
## ICD Test Tier: `sdk`
New tier in the ICD runner. Tests the SDK contract from the browser,
running against the live platform.
### Test Categories
**Boot:**
- `Switchboard.init()` returns an `sw` object with expected shape
- Double-init returns same instance (idempotency)
- `sw.user` is populated after init
- `sw.isAdmin` reflects actual role
- `sw:ready` event fires on `document`
**REST client:**
- `sw.api.get('/api/v1/health')` returns health object
- `sw.api.get('/api/v1/channels')` returns `{ data: [...] }` envelope
- `sw.api.post('/api/v1/channels', {...})` creates and returns channel
- `sw.api.del('/api/v1/channels/:id')` deletes (204 or JSON)
- Auth header injection (no manual token management)
**Events:**
- `sw.on()` receives events emitted by `sw.emit()`
- `sw.once()` fires exactly once
- `sw.off()` stops delivery
- WebSocket events propagate through `sw.on()`
**Theme:**
- `sw.theme.current` returns 'dark' or 'light'
- `sw.theme.set('dark')` changes resolved theme
- `sw.theme.on('change', fn)` fires on toggle
**Pipe registration:**
- `sw.pipe.pre(10, fn)` registers without error
- `sw.pipe.stream(50, fn)` registers without error
- `sw.pipe.render(50, fn)` registers without error
- `sw.pipe.pre(10, fn, { scope: { channelType: ['direct'] } })` registers with scope
- `sw.pipe.list()` returns registered filters in priority order with scope
- Duplicate registration (same source+priority) warns but succeeds
**Pipe execution — pre-send:**
- Register pre-send filter that appends metadata
- Send message → filter runs → metadata present in request
(verify via round-trip: message persisted, reload, check)
- Register halt filter (returns null) → message not sent, toast shown
- Multiple filters → run in priority order
- Filter throws → error logged, next filter runs, message still sends
- Regenerate: filter runs with `ctx.regenerate === true`
**Pipe execution — render:**
- Register render filter that adds a class to message elements
- Send message → response rendered → class present in DOM
- Priority ordering: low-priority filter sees high-priority filter's changes
- Extension compat: `ctx.renderers.register('test', { type: 'post', ... })`
→ filter appears in `sw.pipe.list()` render chain
**Pipe execution — stream:**
- Register stream filter that counts chunks
- Send message → stream completes → filter's counter > 0
- Stream filter that modifies `accumulated` → rendered content reflects change
**Pipe scoping:**
- Register filter scoped to `channelType: ['workflow']`
- Send message on a `direct` channel → filter does NOT run (counter stays 0)
- Send message on a `workflow` channel → filter runs (counter increments)
- Unscoped filter runs on all channel types
- `sw.pipe.list()` shows scope for scoped filters, null for unscoped
**Introspection:**
- `sw.pipe.list()` returns timing stats after filters have run
- Stats include `calls`, `avgMs`, `errors`, `scope` per filter
### Test Infrastructure
The `sdk` tier runs after `crud` (needs a channel to send messages into)
and before `security` (SDK tests assume valid auth). It creates its own
test channels (at least one `direct`, one `workflow` for scope tests),
registers ephemeral filters, sends messages, and verifies DOM/API state.
Estimated test count: 3035 tests.
---
## Changeset Plan
### CS1 — SDK Core + Docs
**Files:** `switchboard-sdk.js`, `DESIGN-0.28.5.md`, `base.html`
**Scope:** SDK object with identity, REST client, events, theme, UI
primitives, component wrappers. No pipe engine yet. `app.js` calls
`Switchboard.init()`. All existing tests stay green — no behavior change.
**Validates:** SDK loads, wraps globals, `sw:ready` fires, `sw.api.*`
works, `sw.theme.*` works. Manual verification: existing surfaces
boot normally with the new script tag.
### CS2 — Pipe Engine + Integration
**Files:** `switchboard-sdk.js` (pipe engine added), `chat.js`,
`ui-core.js`, `ui-format.js`, `extensions.js`
**Scope:** Pipeline execution engine, three filter chains, integration
into sendMessage/streamResponse/runExtensionPostRender, extension
renderer compat shim.
**Validates:** Pipe registration works, filters run in order, existing
block renderers (mermaid, katex, csv, diff) still render via compat
shim. CI green on both PG and SQLite.
**Breaking change:** `runExtensionPostRender()` now delegates to the
pipe engine. If an extension relied on `Extensions.runPostRenderers()`
being called directly (not through `runExtensionPostRender`), it would
need updating. In practice, all callsites go through `runExtensionPostRender`.
### CS3 — ICD Runner SDK Tier
**Files:** `tier-sdk.js`, `main.js`, `manifest.json`, `fixtures.js`
(new fixtures for SDK tests)
**Scope:** Full `sdk` test tier. Delivered as complete `.surface` archive
(installable via `POST /admin/surfaces/install`).
**Validates:** All SDK contract tests pass against live platform.
### CS4 — VERSION + CHANGELOG + ROADMAP
**Files:** `VERSION`, `CHANGELOG.md`, `ROADMAP.md`
**Scope:** Version bump to `0.28.5`, changelog entry, roadmap checkboxes.
---
## Resolved Decisions
1. **Stream filters are sync-only in v0.28.5.** Filters that need async
(e.g., external API calls per chunk) must buffer internally and flush
on their own schedule. Async stream filter support revisited if real
usage demands it. Rationale: backpressure risk on fast SSE streams is
not worth the complexity for MVP.
2. **Pre-send filters run on regenerate.** `regenerateMessage()` runs the
same pre-send chain as `sendMessage()`. The `PreSendContext` includes
`regenerate: true` and `regenerateMessageId: string` so filters can
distinguish. Content policy, KB inject, and all other pre-send filters
apply consistently — a filter that only guards new messages but not
regenerations would be a security gap.
3. **Per-channel filter scoping ships in v0.28.5.** Filters declare an
optional `scope` object at registration time that restricts which
channel types they run on. The pipeline engine checks scope before
invoking the filter — scoped-out filters have zero overhead (no call,
no timing entry). See "Filter Scoping" section below.
4. **KB auto-inject ships as a pipe filter in v0.28.6.** The platform
registers a built-in pre-send filter (`_kb-auto-inject`, priority 5)
that queries bound knowledge bases, runs top-K chunk retrieval, and
prepends context to `ctx.metadata.kb_context`. This is the first
real validation that the pipeline handles non-trivial platform-level
transforms, not just extension toys.
---
## Appendix: Existing Extension Hooks → Pipe Mapping
| DESIGN-SURFACES Hook | Current Implementation | Pipe Equivalent |
|----------------------|----------------------|----------------|
| Hook 2: Stream Processing | Not implemented (spec only) | `sw.pipe.stream()` |
| Hook 3: Post-Render | `ctx.renderers.register(type:'post')``Extensions.runPostRenderers()` | `sw.pipe.render()` |
| Hook 3: Block Render | `ctx.renderers.register(type:'block')``Extensions.runBlockRenderers()` | `sw.pipe.render()` (filter targets code blocks in DOM) |
| Hook 3: Inline Render | `ctx.renderers.register(type:'inline')` → not wired | `sw.pipe.render()` (filter modifies HTML string) |
| Hook 5: Tool Bridge | `ctx.tools.handle()``Events.on('tool.call.*')` | Unchanged — not a pipe stage |
| Hook 6: Surface Injection | Not implemented (spec only) | Not a pipe stage — future SDK feature |
| (new) Pre-send | Did not exist | `sw.pipe.pre()` |

View File

@@ -1,348 +0,0 @@
# Frontend JS Dependency Audit
**Version:** v0.28.3 cs2
**Date:** 2026-03-13
**Purpose:** Map every global, cross-file dependency, and init sequence
to prepare for module extraction.
---
## Summary
| Metric | Value |
|--------|-------|
| JS files | 47 |
| Total lines | 25,056 |
| Total bytes | 1,103,572 (~1.1 MB) |
| Top-level globals (original) | 431 |
| IIFE-wrapped files | 47 (all) |
| Explicit `window.*` exports | ~215 |
| Functions privatized | ~168 |
| Implicit globals remaining | 0 |
| Vendor libs | 3 (marked.min.js, purify.min.js, codemirror.bundle.js) |
| Inline `onclick` handlers (templates) | ~50 unique functions |
| Dynamic `onclick` in JS (innerHTML) | 143 total occurrences |
---
## Architecture: IIFE Modules (Post-Decomposition)
No module system (no ES modules, no bundler, no AMD). Every file is a
`<script>` tag loaded in dependency order. All 47 files are now wrapped
in IIFEs with explicit `window.*` exports:
```js
(function() {
'use strict';
// ... all code ...
window.MyGlobal = MyGlobal;
})();
```
**Phase 1 complete.** Zero implicit globals remain. ~168 functions
privatized. The next phase is onclick → addEventListener migration
(143 dynamic handlers in JS innerHTML + ~50 in Go templates), which
is the prerequisite for ES module conversion.
### IIFE Files
| File | Clean? | Leaks |
|------|--------|-------|
| `admin-handlers.js` | ✅ | None (all via inline onclick from globals) |
| `admin-scaffold.js` | ✅ | None |
| `editor-surface.js` | ✅ | None |
| `knowledge-ui.js` | ✅ | None |
| `pages-splash.js` | ✅ | None |
| `task-admin.js` | ❌ | `window._loadAdminTasks` |
| `task-settings.js` | ❌ | `window._createFromTemplate`, `window._loadSettingsTasks` |
| `task-sidebar.js` | ❌ | `window.TaskSidebar` |
| `tools-toggle.js` | ✅ | None |
| `workflow-admin.js` | ❌ | 5 functions (`_loadAdminWorkflows`, `_wfCreate`, `_wfOpen`, `_wfEditStage`, `_wfDeleteStage`) |
| `workflow-api.js` | ✅ | None (registers on App) |
| `workflow-queue.js` | ❌ | `window.WorkflowQueue` |
---
## Script Load Order
### base.html (shared — every surface)
```
1. app-state.js → const App = { ... }
2. api.js → const API = { ... }
3. (inline) → window.__BASE__, auth bootstrap
4. events.js → const Events = { ... }
5. ui-primitives.js → const UI = { ... }
6. vendor/marked.min.js
7. vendor/purify.min.js
8. ui-format.js → formatMessage(), toggleCodeCollapse(), etc.
9. ui-core.js → renderChatList(), renderMessages(), etc.
10. pages.js → const Pages = { ... }
11. user-menu.js → const UserMenu = { ... }
12. model-selector.js → const ModelSelector = { ... }
13. file-tree.js → const FileTree = { ... }
14. code-editor.js → const CodeEditor = { ... }
15. note-editor.js → const NoteEditor = { ... }
16. drag-resize.js → const DragResize = { ... }
17. pane-container.js → const PaneContainer = { ... }
18. (inline) → Theme.init()
19. surfaces/{surface}/js/main.js → surface CSS + scaffold
```
### chat.html (25 additional scripts + 1 vendor)
```
vendor/codemirror/codemirror.bundle.js → window.CM (optional, onerror fallback)
20. extensions.js → const Extensions = { ... }
21. panels.js → const PanelRegistry = { ... }
22. ui-settings.js → settings rendering (bare globals)
23. ui-admin.js → admin rendering (bare globals)
24. tokens.js → token counting (bare globals)
25. notes.js → notes UI (bare globals)
26. note-graph.js → graph visualization (bare globals)
27. files.js → file upload/management (bare globals)
28. tools-toggle.js → tool enable/disable (IIFE)
29. knowledge-ui.js → KB picker (IIFE)
30. memory-ui.js → memory panel (bare globals)
31. persona-kb.js → persona KB bindings (bare globals)
32. projects-ui.js → project management (bare globals, 78 top-level decls)
33. channel-models.js → const ChannelModels = { ... }
34. notification-prefs.js → notification preferences (bare globals)
35. notifications.js → const Notifications = { ... }
36. chat.js → const ChatInput = { ... } + 29 top-level functions
37. settings-handlers.js → settings form handlers (bare globals, 24 decls)
38. admin-handlers.js → admin form handlers (IIFE, 40 internal functions)
39. workflow-api.js → workflow REST helpers (IIFE)
40. workflow-admin.js → workflow admin UI (IIFE, 5 window.* leaks)
41. workflow-queue.js → workflow queue sidebar (IIFE, window.WorkflowQueue)
42. task-sidebar.js → task sidebar (IIFE, window.TaskSidebar)
43. app.js → init(), startApp(), event listeners
44. debug.js → debug console (bare globals)
45. repl.js → REPL (bare globals)
```
### admin.html (14 additional)
Loads: ui-settings, ui-admin, admin-handlers, settings-handlers,
knowledge-ui, persona-kb, memory-ui, notifications, files,
admin-scaffold, workflow-api, workflow-admin, task-admin,
admin-surfaces.
### notes.html (14 additional)
Loads: chat, files, channel-models, tokens, extensions, notes,
note-graph, knowledge-ui, persona-kb, memory-ui, notifications,
panels, projects-ui, app.
### settings.html (9 additional)
Loads: ui-settings, settings-handlers, task-settings, knowledge-ui,
persona-kb, memory-ui, notifications, notification-prefs,
channel-models.
### editor.html (4 additional + 1 vendor)
Loads: vendor/codemirror, chat-pane, editor-surface, app.
Lightest surface — fewest dependencies.
---
## Core Globals (Dependency Roots)
These are referenced by 10+ other files. They form the "root set" that
must be extracted first.
| Global | Defined in | Referenced by | Role |
|--------|-----------|---------------|------|
| `App` | app-state.js | 20 files | State container (chats, models, user, settings, presence) |
| `API` | api.js | 36 files | REST client (all HTTP calls) |
| `UI` | ui-core.js | 32 files | DOM helpers (toast, confirm, modal, sidebar, theme) |
| `Events` | events.js | 9 files | Pub/sub event bus (WebSocket bridge) |
### Secondary Globals (39 references)
| Global | Defined in | Referenced by | Role |
|--------|-----------|---------------|------|
| `Pages` | pages.js | Templates + 3 files | Page-level actions (login, admin forms) |
| `ChannelModels` | channel-models.js | 4 files | Model roster per channel |
| `ChatInput` | chat.js | 3 files | Message input state + send |
| `Notifications` | notifications.js | Templates + 2 files | Bell dropdown, badge count |
| `ChatPane` | chat-pane.js | 3 files | Embedded chat component (editor) |
| `CM` | vendor/codemirror.bundle.js | 5 files | CodeMirror 6 editor (optional, graceful fallback) |
| `PanelRegistry` | panels.js | Templates + 1 file | Side panel management |
### Leaf Globals (12 references, mostly self-contained)
ModelSelector, FileTree, CodeEditor, NoteEditor, DragResize,
PaneContainer, UserMenu, WorkflowQueue, TaskSidebar, Extensions.
---
## Inline Handler Problem
143 `onclick=` patterns in JS files (innerHTML-generated HTML) plus
~50 in Go templates. These reference bare globals that **must remain
on `window`** or the handlers break. This is the primary blocker for
ES module migration.
**Top offenders (dynamic onclick in JS):**
| File | Count | Examples |
|------|-------|---------|
| ui-admin.js | 28 | Admin list row actions |
| projects-ui.js | 26 | Project CRUD, channel/KB/note associations |
| ui-core.js | 25 | Chat list, sidebar, context menus |
| ui-format.js | 10 | Code block copy/preview/download |
| ui-settings.js | 8 | Settings form toggles |
| notifications.js | 8 | Notification row actions |
**Template onclick (must remain global):**
UI.toggleSidebar, UI.toggleSidebarSection, UI.switchTeamTab,
Notifications.toggleDropdown, PanelRegistry.toggle, PanelRegistry.closeAll,
Pages.doLogin, Pages.saveSettings, Pages.saveProvider, Pages.saveTeam,
handleLogin, handleRegister, switchAuthTab, newChat, newFolder,
newChannelOrDM, sendMessage, createProject, closeLightbox,
toggleSidePanelFullscreen, plus ~15 more Pages.* admin form handlers.
---
## Cross-File Call Graph (Simplified)
```
app-state.js (App)
↑ read/write by 20 files
api.js (API)
↑ called by 36 files
events.js (Events)
↑ subscribed by 9 files
├── chat.js (typing, message.created, presence)
├── notifications.js (notification.new/read)
├── extensions.js (tool.call/result)
├── repl.js (debug events)
└── app.js (role.fallback banner)
ui-primitives.js (UI)
↑ called by 32 files
├── toast(), confirm(), modal()
├── toggleSidebar(), toggleSidebarSection()
└── theme, kbd shortcuts
ui-format.js (formatMessage, etc.)
↑ called by ui-core.js, chat.js, notes.js, projects-ui.js
├── depends on: marked.min.js, purify.min.js
ui-core.js (renderChatList, renderMessages, etc.)
↑ called by chat.js, app.js, projects-ui.js
├── depends on: App, API, UI, ui-format
chat.js (ChatInput, sendMessage, selectChat, loadChats)
↑ called by app.js, templates
├── depends on: App, API, Events, UI, ui-core, ui-format,
│ ChannelModels, tokens, files
app.js (init, startApp)
↑ entry point — called by inline <script> in base.html
├── depends on: everything above + surface-specific modules
```
---
## Decomposition Strategy
### Phase 1: Extract Core Four (no bundler required)
Move `App`, `API`, `Events`, `UI` to a pattern where they're defined
as proper singletons with explicit exports. No ES modules yet — just
clean up the global registration so each file has a single `window.X`
export with a documented interface.
**Why no bundler yet:** 143 dynamic onclick handlers + 50 template
onclick handlers require globals on `window`. A bundler would need
either (a) a global shim layer or (b) converting all handlers to
`addEventListener`. Option (b) is the right answer but it's ~200
call sites across 16 files + 10 templates — too much churn for one
changeset.
### Phase 2: onclick → addEventListener migration
Convert dynamic HTML generation from:
```js
`<button onclick="deleteItem('${id}')">Delete</button>`
```
to:
```js
const btn = document.createElement('button');
btn.textContent = 'Delete';
btn.addEventListener('click', () => deleteItem(id));
```
This eliminates the global requirement. Target the top 6 files first
(ui-admin, projects-ui, ui-core, ui-format, ui-settings, notifications)
which account for 105 of 143 occurrences.
Template onclick handlers stay — Go templates can't do addEventListener.
These become the only remaining reason for `window.*` exports.
### Phase 3: ES Module conversion
Once onclick handlers are gone from JS, files can become ES modules:
```html
<script type="module" src="js/app.js"></script>
```
Import graph follows the dependency order already established by
`<script>` tag ordering. Vendor libs (marked, purify, codemirror) stay
as classic scripts since they self-register on `window`.
### Phase 4: Template handler shim
Create a thin `window.handlers = {}` object that ES modules register
into. Template onclick handlers call `handlers.doLogin()` etc. This
is the final bridge — once templates move to client-side rendering
(extension surface SDK), this shim disappears.
---
## Files by Decomposition Priority
### Tier 1 — Core (extract first, most dependents)
| File | Lines | Globals | Dependents | Notes |
|------|-------|---------|------------|-------|
| app-state.js | 96 | 3 | 20 | Clean singleton, easy extract |
| api.js | 1,159 | 3 | 36 | Large but self-contained |
| events.js | 261 | 1 | 9 | Clean singleton |
| ui-primitives.js | 1,316 | 18 | 32 | Large, many utility functions |
### Tier 2 — Rendering (depends on Tier 1)
| File | Lines | Globals | Dependents | Notes |
|------|-------|---------|------------|-------|
| ui-format.js | 573 | 20 | 4 | Markdown + code blocks |
| ui-core.js | 1,644 | 6 | 3 | Chat list, messages, sidebar |
| pages.js | 382 | 8 | Templates | Page-level orchestration |
### Tier 3 — Feature modules (leaf nodes, mostly self-contained)
| File | Lines | Notes |
|------|-------|-------|
| channel-models.js | 425 | Clean object literal |
| chat-pane.js | 143 | Clean object literal |
| notifications.js | 390 | Clean singleton, IIFE candidate |
| model-selector.js | 191 | Clean object literal |
| file-tree.js | 295 | Clean object literal |
| tokens.js | 143 | 4 globals, minimal deps |
### Tier 4 — Heavy pages (most onclick handlers, refactor last)
| File | Lines | onclick | Notes |
|------|-------|---------|-------|
| ui-admin.js | 1,922 | 28 | Admin rendering, deeply coupled |
| projects-ui.js | 1,794 | 26 | 78 top-level globals (!), biggest mess |
| ui-core.js | 1,644 | 25 | Already in Tier 2, but onclick heavy |
| ui-settings.js | 1,011 | 8 | Settings rendering |
| settings-handlers.js | 1,064 | 0 | But 24 globals called by ui-settings |
| admin-handlers.js | 952 | 2 | IIFE but 40 internal functions |

View File

@@ -43,7 +43,7 @@ v0.9.xv0.28.7 Foundation through Platform Polish ✅
│ │
│ v0.35.0 Workflow Product
│ │
│ v0.36.0 Full OpenAPI Spec
│ v0.36.0 Full OpenAPI Spec
│ │
══════╪═══════════════════════════════╪══════
│ MVP v0.50.0 │
@@ -410,7 +410,7 @@ Depends on: v0.32.0 (multi-replica metrics aggregation).
WS connections, Go runtime stats, storage status, recent errors,
30s auto-refresh.
**Deferred to v0.36.0:**
**Deferred to v0.36.0 (delivered):**
- Full OpenAPI spec coverage (all 20 ICD domains)
- Bearer token / mTLS auth documentation in spec
@@ -484,7 +484,7 @@ Depends on: v0.31.2 (team workflow self-service), v0.29.0 (Starlark sandbox).
- Data intake: form → Starlark enrichment → human review → follow-up
- Onboarding: multi-stage form → conditional branching → team assignment
### v0.36.0 — Full OpenAPI Spec
### v0.36.0 — Full OpenAPI Spec
Complete OpenAPI 3.0.3 coverage for every API domain. Expand the
hand-curated spec from v0.33.0 (core groups only) to cover all 20 ICD
@@ -493,38 +493,38 @@ test domains. Document Bearer token auth and mTLS modes.
Depends on: v0.33.0 (Swagger UI + initial spec).
**API domains to document (matching ICD test suite):**
- [ ] Admin (system settings, stats, storage, users, provider config)
- [ ] Channels (full CRUD + membership, already partially covered)
- [ ] Completions (streaming + non-streaming, already partially covered)
- [ ] Extensions (package install, permissions, lifecycle)
- [ ] Knowledge (KB articles, sources, search, embeddings)
- [ ] Memory (conversation memory, compaction, search)
- [ ] Models (provider models, BYOK, capability matching)
- [ ] Notes (CRUD, graph links, search)
- [ ] Notifications (list, mark read, preferences)
- [ ] Personas (CRUD, system prompts, tool config)
- [ ] Profile (user profile, preferences, avatar)
- [ ] Projects (CRUD, membership, file uploads)
- [ ] Surfaces (extension surfaces, mounting, lifecycle)
- [ ] Tasks (scheduled tasks, execution history, Starlark tasks)
- [ ] Teams (CRUD, membership, roles, slugs)
- [ ] Team Workflows (team-scoped CRUD, stages, publish)
- [ ] Workflows (platform-level CRUD, stages, instances, assignments)
- [ ] Workspaces (CRUD, membership, settings)
- [ ] Dashboard Package (package-specific API routes)
- [ ] Editor Package (package-specific API routes)
- [x] Admin (system settings, stats, storage, users, provider config)
- [x] Channels (full CRUD + membership, already partially covered)
- [x] Completions (streaming + non-streaming, already partially covered)
- [x] Extensions (package install, permissions, lifecycle)
- [x] Knowledge (KB articles, sources, search, embeddings)
- [x] Memory (conversation memory, compaction, search)
- [x] Models (provider models, BYOK, capability matching)
- [x] Notes (CRUD, graph links, search)
- [x] Notifications (list, mark read, preferences)
- [x] Personas (CRUD, system prompts, tool config)
- [x] Profile (user profile, preferences, avatar)
- [x] Projects (CRUD, membership, file uploads)
- [x] Surfaces (extension surfaces, mounting, lifecycle)
- [x] Tasks (scheduled tasks, execution history, Starlark tasks)
- [x] Teams (CRUD, membership, roles, slugs)
- [x] Team Workflows (team-scoped CRUD, stages, publish)
- [x] Workflows (platform-level CRUD, stages, instances, assignments)
- [x] Workspaces (CRUD, membership, settings)
- [x] Dashboard Package (package-specific API routes)
- [x] Editor Package (package-specific API routes)
**Auth documentation:**
- [ ] Bearer JWT flow (login → token → `Authorization: Bearer <token>`)
- [ ] mTLS mode (NPE-to-NPE, no Bearer needed)
- [ ] API key mode (for service-to-service integrations)
- [ ] Security scheme definitions in OpenAPI spec
- [x] Bearer JWT flow (login → token → `Authorization: Bearer <token>`)
- [x] mTLS mode (NPE-to-NPE, no Bearer needed)
- [x] API key mode (for service-to-service integrations)
- [x] Security scheme definitions in OpenAPI spec
**Quality:**
- [ ] Request/response schemas with examples for every endpoint
- [ ] Error response schemas (400, 401, 403, 404, 409, 422, 429, 500)
- [ ] Pagination parameters documented consistently
- [ ] WebSocket event schemas (connection, ticket, event types)
- [x] Request/response schemas with examples for every endpoint
- [x] Error response schemas (400, 401, 403, 404, 409, 422, 429, 500)
- [x] Pagination parameters documented consistently
- [x] WebSocket event schemas (connection, ticket, event types)
---