Changeset 0.28.5 (#191)
This commit is contained in:
57
CHANGELOG.md
57
CHANGELOG.md
@@ -1,5 +1,62 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.28.5] — 2026-03-14
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Frontend SDK + pipe/filter pipeline. `switchboard-sdk.js` provides a
|
||||||
|
single-object composition layer (`sw`) over the 15+ platform globals.
|
||||||
|
Three-stage pipe/filter pipeline (pre-send, post-receive stream,
|
||||||
|
post-render) formalizes the ad-hoc extension hook model into composable,
|
||||||
|
priority-ordered, scope-aware transform chains. Backward-compatible
|
||||||
|
compat shim for existing `ctx.renderers.register()` extensions. ICD
|
||||||
|
runner gains `sdk` test tier (36 tests).
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- **`switchboard-sdk.js`** — SDK core. `Switchboard.init()` returns
|
||||||
|
`sw` singleton with identity (`sw.user`, `sw.isAdmin`), REST client
|
||||||
|
(`sw.api.get/post/put/del/stream`), events (`sw.on/once/off/emit`),
|
||||||
|
theme (`sw.theme.current/mode/set/on`), UI primitives
|
||||||
|
(`sw.toast/confirm/modal`), and component wrappers (`sw.chat()`).
|
||||||
|
- **Pipe/filter pipeline** — three composable stages:
|
||||||
|
- `sw.pipe.pre(priority, fn, opts)` — pre-send: transform user message
|
||||||
|
before LLM request. Runs on both `sendMessage()` and `regenerateMessage()`.
|
||||||
|
Context includes `regenerate` flag and `regenerateMessageId`.
|
||||||
|
- `sw.pipe.stream(priority, fn, opts)` — post-receive: transform each
|
||||||
|
SSE chunk during streaming. Sync-only for performance.
|
||||||
|
- `sw.pipe.render(priority, fn, opts)` — post-render: transform rendered
|
||||||
|
DOM after DOMPurify. Replaces `runExtensionPostRender()` delegation.
|
||||||
|
- **Filter scoping** — filters declare optional `scope: { channelType: [...] }`
|
||||||
|
to restrict execution to specific channel types. Scoped-out filters are
|
||||||
|
skipped with zero overhead.
|
||||||
|
- **Pipeline introspection** — `sw.pipe.list()` returns all registered
|
||||||
|
filters by stage with priority, source, scope, call count, avgMs, errors.
|
||||||
|
- **Extension compat shim** — `ctx.renderers.register(name, { type: 'post' })`
|
||||||
|
auto-shims into `sw.pipe.render()`. Existing block renderers (mermaid,
|
||||||
|
katex, csv, diff) continue working through the unchanged `formatMessage()`
|
||||||
|
path. Shimmed renderers appear in `sw.pipe.list()`.
|
||||||
|
- **ICD runner `sdk` tier** — 36 tests: boot, identity, REST client,
|
||||||
|
events, theme, pipe registration, execution ordering, scoping, halt
|
||||||
|
semantics, error isolation, compat shim, introspection.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `base.html` — SDK script tag added after component scripts. Universal
|
||||||
|
init block simplified to `Switchboard.init()` call. UserMenu band-aid
|
||||||
|
(cs15) absorbed into SDK `_hydrateUserMenu()`.
|
||||||
|
- `app.js` — `startApp()` calls `Switchboard.init()` (idempotent).
|
||||||
|
- `chat.js` — `sendMessage()` and `regenerateMessage()` run pre-send
|
||||||
|
pipe. New `_buildPreSendContext()` helper.
|
||||||
|
- `ui-core.js` — `streamResponse()` SSE loop runs stream pipe per chunk.
|
||||||
|
- `ui-format.js` — `runExtensionPostRender()` delegates to render pipe
|
||||||
|
with fallback to `Extensions.runPostRenderers()`.
|
||||||
|
- `extensions.js` — `_registerRenderer()` shims `type: 'post'` renderers
|
||||||
|
into `sw.pipe.render()`.
|
||||||
|
- ICD runner version bumped to 0.28.5.0, `sdk` tier added.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## [0.28.4] — 2026-03-14
|
## [0.28.4] — 2026-03-14
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
754
docs/DESIGN-0.28.5.md
Normal file
754
docs/DESIGN-0.28.5.md
Normal file
@@ -0,0 +1,754 @@
|
|||||||
|
# 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: 30–35 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()` |
|
||||||
423
docs/ROADMAP.md
423
docs/ROADMAP.md
@@ -27,14 +27,19 @@ v0.9.x–v0.27.5 Foundation → Extensions → Surfaces → Auth ✅
|
|||||||
├─ v0.28.2 ICD audit — all domains ✅
|
├─ v0.28.2 ICD audit — all domains ✅
|
||||||
├─ v0.28.3 ICD close-out + FE decomp ✅
|
├─ v0.28.3 ICD close-out + FE decomp ✅
|
||||||
├─ v0.28.4 Security tier (red team) ✅
|
├─ v0.28.4 Security tier (red team) ✅
|
||||||
├─ v0.28.5 Infrastructure
|
├─ v0.28.5 Frontend SDK + Pipes ✅
|
||||||
│ (virtual scroll, KB auto-inject,
|
│ (switchboard-sdk.js, pipe/filter
|
||||||
│ Helm chart, provider model prefs,
|
│ pipeline, component mounting)
|
||||||
│ git credentials UI)
|
├─ v0.28.6 Infrastructure
|
||||||
└─ v0.28.6 Frontend SDK
|
│ (virtual scroll, Helm, task webhook
|
||||||
|
│ UI, system tasks, model prefs)
|
||||||
|
└─ v0.28.7 Unified Packaging + Task RBAC
|
||||||
|
(.pkg archive, manifest.type,
|
||||||
|
task permission gate pre-Starlark)
|
||||||
│
|
│
|
||||||
v0.29.0 Starlark Sandbox
|
v0.29.0 Starlark Sandbox
|
||||||
(eval loop, permissions, admin UI)
|
(eval loop, permissions, admin UI
|
||||||
|
— adds starlark capability to .pkg)
|
||||||
│
|
│
|
||||||
v0.29.1 API Extensions
|
v0.29.1 API Extensions
|
||||||
(Starlark route handlers,
|
(Starlark route handlers,
|
||||||
@@ -44,13 +49,26 @@ v0.9.x–v0.27.5 Foundation → Extensions → Surfaces → Auth ✅
|
|||||||
(namespaced tables, scoped db module,
|
(namespaced tables, scoped db module,
|
||||||
declarative schema in manifest)
|
declarative schema in manifest)
|
||||||
│
|
│
|
||||||
v0.30.0 Surface Packages
|
v0.29.3 Workflow Forms
|
||||||
(full-stack .surface archives:
|
(form_template → real UI, LLM optional,
|
||||||
UI + API + schema + settings)
|
.star validation, structured data)
|
||||||
│
|
│
|
||||||
v0.31.0 Editor Surface Package
|
v0.30.0 Package Lifecycle
|
||||||
|
(schema versioning, migrations,
|
||||||
|
settings extension point,
|
||||||
|
export/import, marketplace)
|
||||||
|
│
|
||||||
|
v0.30.1 SDK Adoption
|
||||||
|
(sw.notes factory, core surface
|
||||||
|
migration, fix-once-fix-everywhere)
|
||||||
|
│
|
||||||
|
v0.30.2 Workflow Packages
|
||||||
|
(stage surfaces, custom UI per stage,
|
||||||
|
team admin workflow builder)
|
||||||
|
│
|
||||||
|
v0.31.0 Editor Package
|
||||||
(E2E proof: rebuild editor as
|
(E2E proof: rebuild editor as
|
||||||
installable surface, zero
|
installable .pkg, zero
|
||||||
platform special-casing)
|
platform special-casing)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -227,9 +245,61 @@ New `security` tier in ICD test runner. 58 tests, 529/531 pass.
|
|||||||
- [x] `surfaces/` directory with `build.sh` → `dist/<n>.surface`
|
- [x] `surfaces/` directory with `build.sh` → `dist/<n>.surface`
|
||||||
- [x] `hello-dashboard.surface` unpacked into `surfaces/hello-dashboard/`
|
- [x] `hello-dashboard.surface` unpacked into `surfaces/hello-dashboard/`
|
||||||
|
|
||||||
### v0.28.5 — Infrastructure
|
### v0.28.5 — Frontend SDK + Pipes ✅
|
||||||
|
`switchboard-sdk.js` — composition layer over existing globals. Surface
|
||||||
|
authors consume a single coherent API instead of hunting through 15 JS files.
|
||||||
|
Pipe/filter pipeline formalizes the extension hook model into composable,
|
||||||
|
priority-ordered transform stages.
|
||||||
|
|
||||||
|
**SDK core:**
|
||||||
|
- [x] `Switchboard.init({ mount })` — idempotent boot: tokens, profile, theme, events, user menu
|
||||||
|
- [x] `sw.user`, `sw.isAdmin` — resolved identity
|
||||||
|
- [x] `sw.api.get()` / `sw.api.post()` — authenticated REST, no token/base-path management
|
||||||
|
- [x] `sw.on(event, fn)` — WebSocket subscription (no `Events` global knowledge)
|
||||||
|
- [x] `sw.chat(container, opts)` — drop-in ChatPane (wraps `ChatPane.create()`)
|
||||||
|
- [ ] `sw.notes(container, opts)` — stub shipped (warns); notes component
|
||||||
|
needs factory refactor before clean mounting (v0.30.1 SDK Adoption)
|
||||||
|
- [x] `sw.toast()`, `sw.confirm()` — UI primitives
|
||||||
|
- [x] `sw.theme.current`, `sw.theme.on('change', fn)` — theme queries
|
||||||
|
- [x] Absorbs cs15 UserMenu band-aid — universal hydration moves into `init()`
|
||||||
|
|
||||||
|
**Pipe/filter pipeline:**
|
||||||
|
|
||||||
|
Three stages, each a priority-ordered chain of transform functions.
|
||||||
|
Filters receive typed context, return (possibly modified) context.
|
||||||
|
Pipeline wires filters in priority order; any filter can halt the chain.
|
||||||
|
|
||||||
|
- [x] `sw.pipe.pre(priority, fn)` — **pre-send**: transform user message
|
||||||
|
before LLM request. Context: `{ message, channel, attachments, metadata }`.
|
||||||
|
Runs on both `sendMessage()` and `regenerateMessage()` with `regenerate` flag.
|
||||||
|
- [x] `sw.pipe.stream(priority, fn)` — **post-receive**: transform LLM
|
||||||
|
response stream chunks as they arrive (sync-only). Context: `{ chunk,
|
||||||
|
accumulated, channel, model }`.
|
||||||
|
- [x] `sw.pipe.render(priority, fn)` — **post-render**: transform rendered
|
||||||
|
HTML/markdown after DOMPurify. Context: `{ element, message, channel }`.
|
||||||
|
Replaces `runExtensionPostRender()` with compat shim for existing
|
||||||
|
`ctx.renderers.register()` extensions.
|
||||||
|
- [x] Pipeline execution engine: ordered dispatch, error isolation (one
|
||||||
|
filter throws → skip it, continue chain), timing telemetry per filter
|
||||||
|
- [x] `sw.pipe.list()` — introspection: list registered filters by stage
|
||||||
|
with priority, source, scope, and timing stats
|
||||||
|
- [x] Filter scoping: `{ scope: { channelType: [...] } }` restricts
|
||||||
|
execution to specific channel types with zero-overhead skip
|
||||||
|
- [ ] Manifest `"pipes"` key: declarative filter registration — deferred
|
||||||
|
to v0.28.7 (unified packaging, where manifest keys are extended)
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
- [x] ICD runner gains `sdk` test tier: 36 tests — boot, identity, REST,
|
||||||
|
events, theme, pipe registration, execution, scoping, halt, error
|
||||||
|
isolation, compat shim, introspection
|
||||||
|
- [x] Pipe filter tests: priority ordering, halt semantics, error isolation,
|
||||||
|
scoped/unscoped execution
|
||||||
|
|
||||||
|
### v0.28.6 — Infrastructure
|
||||||
- [ ] Virtual scroll for long conversations (prerequisite for heavy task output channels)
|
- [ ] Virtual scroll for long conversations (prerequisite for heavy task output channels)
|
||||||
- [ ] KB auto-injection: top-K chunk prepend, context budget aware, per-channel toggle
|
- [ ] KB auto-injection: platform-registered pre-send pipe filter (`_kb-auto-inject`,
|
||||||
|
priority 5), top-K chunk prepend to `ctx.metadata.kb_context`, context budget aware,
|
||||||
|
per-channel toggle. First real validation of the v0.28.5 pipeline architecture.
|
||||||
- [ ] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`)
|
- [ ] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`)
|
||||||
- [ ] Per-provider model preferences — finalize: make `provider_config_id` required on
|
- [ ] Per-provider model preferences — finalize: make `provider_config_id` required on
|
||||||
`PUT /models/preferences` (fixes NULL-in-UNIQUE dedup bug), migrate `/models/enabled`
|
`PUT /models/preferences` (fixes NULL-in-UNIQUE dedup bug), migrate `/models/enabled`
|
||||||
@@ -240,23 +310,107 @@ New `security` tier in ICD test runner. 58 tests, 529/531 pass.
|
|||||||
- [ ] `system.announcement` notification type: admin broadcast endpoint
|
- [ ] `system.announcement` notification type: admin broadcast endpoint
|
||||||
(`POST /admin/notifications/broadcast`), fan-out to all active users via
|
(`POST /admin/notifications/broadcast`), fan-out to all active users via
|
||||||
`NotifyMany`, admin UI for composing announcements
|
`NotifyMany`, admin UI for composing announcements
|
||||||
|
- [ ] Task webhook trigger UI: schedule selector gains `webhook` option,
|
||||||
|
trigger URL displayed + copy button, outbound `webhook_url` + `webhook_secret`
|
||||||
|
fields in create/edit form, task-to-task chaining documentation in admin UI.
|
||||||
|
Backend fully implemented — this is pure admin UI work.
|
||||||
|
- [ ] System task type: `task_type: "system"` — built-in Go function registry
|
||||||
|
(`retention_sweep`, `memory_compact`, `session_cleanup`, `staleness_check`).
|
||||||
|
Admin creates task, picks function from dropdown, sets cron schedule. Executor
|
||||||
|
calls registered Go function instead of LLM completion. Replaces the current
|
||||||
|
goroutine-based background jobs with visible, configurable, auditable tasks.
|
||||||
|
Existing goroutines kept as fallback until system tasks are validated.
|
||||||
|
**Permanent track** — Go registry is not replaced by Starlark (v0.29.0).
|
||||||
|
Core platform ops must not break from bad user code. Admin-only by design
|
||||||
|
(no RBAC needed — hardcoded to admin role).
|
||||||
|
|
||||||
### v0.28.6 — Frontend SDK
|
### v0.28.7 — Unified Packaging + Task RBAC
|
||||||
`switchboard-sdk.js` — composition layer over existing globals. Surface
|
Single `.pkg` archive format for both surfaces and extensions. Manifest
|
||||||
authors consume a single coherent API instead of hunting through 15 JS files.
|
`"type"` field (`surface`, `extension`, `full`) drives install behavior.
|
||||||
|
Format first, capabilities later — v0.29.x adds Starlark/DB/API capabilities
|
||||||
|
into packages that already install cleanly.
|
||||||
|
|
||||||
- [ ] `Switchboard.init({ mount })` — idempotent boot: tokens, profile, theme, events, user menu
|
Task permission model gates task creation by type and scope. Retroactively
|
||||||
- [ ] `sw.user`, `sw.isAdmin` — resolved identity
|
locks down `action` tasks (webhook relay = data exfiltration risk) and
|
||||||
- [ ] `sw.api.get()` / `sw.api.post()` — authenticated REST, no token/base-path management
|
pre-positions for `starlark` tasks in v0.29.0.
|
||||||
- [ ] `sw.on(event, fn)` — WebSocket subscription (no `Events` global knowledge)
|
|
||||||
- [ ] `sw.chat(container, opts)` — drop-in ChatPane (wraps `ChatPane.create()`)
|
Depends on: v0.28.5 (SDK — pipe/filter registration is part of manifest contract).
|
||||||
- [ ] `sw.notes(container, opts)` — drop-in notes component
|
|
||||||
- [ ] `sw.toast()`, `sw.confirm()` — UI primitives
|
**Archive format:**
|
||||||
- [ ] `sw.theme.current`, `sw.theme.on('change', fn)` — theme queries
|
- [ ] `.pkg` archive: zip containing `manifest.json` + assets (JS, CSS,
|
||||||
- [ ] ICD runner gains `sdk` test tier: validates init, component mounting, event hooks
|
templates, icons). Same structure as `.surface` archives, extended
|
||||||
- [ ] Absorbs cs15 UserMenu band-aid — universal hydration moves into `init()`
|
manifest schema
|
||||||
|
- [ ] `manifest.type` field: `surface` (routes + templates + data loader),
|
||||||
|
`extension` (hooks + tools + pipes, no own route), `full` (both).
|
||||||
|
Absent `type` defaults to `surface` for backward compat
|
||||||
|
- [ ] `manifest.pipes` key: declarative pipe filter registration (pre-send,
|
||||||
|
post-receive, post-render) with priority and entry function reference.
|
||||||
|
Wired by SDK on extension load
|
||||||
|
- [ ] `manifest.tools` key: LLM-callable tool declarations (existing schema,
|
||||||
|
now part of unified manifest)
|
||||||
|
- [ ] `manifest.hooks` key: EventBus subscriptions (existing schema from
|
||||||
|
EXTENSIONS.md, carried forward)
|
||||||
|
|
||||||
|
**Install infrastructure:**
|
||||||
|
- [ ] `POST /admin/packages/install` — unified install endpoint. Reads
|
||||||
|
`manifest.type`, validates type-specific requirements, wires subsystems.
|
||||||
|
Replaces `POST /admin/surfaces/install` (old route kept as alias)
|
||||||
|
- [ ] Validation branches by type: `surface` requires `routes`/`template`,
|
||||||
|
`extension` requires at least one of `hooks`/`tools`/`pipes`, `full`
|
||||||
|
requires both sets
|
||||||
|
- [ ] Admin UI: merge Surfaces + Extensions into single "Packages" section.
|
||||||
|
Type badge on each entry. Filter by type. Enable/disable granular
|
||||||
|
for `full` packages (disable hooks independently of routes)
|
||||||
|
- [ ] `packages` table (or rename `surfaces` → `packages` with migration):
|
||||||
|
adds `type` column, retains all existing surface columns
|
||||||
|
|
||||||
|
**Migration:**
|
||||||
|
- [ ] Existing `.surface` archives install unchanged (type defaults to
|
||||||
|
`surface`). No re-install required
|
||||||
|
- [ ] Existing `extensions` table rows (loose-JS browser extensions) get
|
||||||
|
synthetic package wrappers: migration generates `manifest.json` from
|
||||||
|
existing DB fields, archives JS entry file into `.pkg` format
|
||||||
|
- [ ] `build.sh` in `surfaces/` updated to produce `.pkg` files. Directory
|
||||||
|
optionally renamed to `packages/`
|
||||||
|
- [ ] ICD runner surface install tests updated to use new endpoint (or alias)
|
||||||
|
|
||||||
|
**Task RBAC:**
|
||||||
|
|
||||||
|
Permission gate on task creation by `task_type` and `scope`. Must ship
|
||||||
|
before Starlark (v0.29.0) — cannot allow server-side code execution
|
||||||
|
without a permission model. Also retroactively locks down `action` tasks.
|
||||||
|
|
||||||
|
- [ ] `task_permissions` table (or extend `extension_permissions` pattern):
|
||||||
|
maps `(user_id | team_id | role) → task_type → allowed`. Default
|
||||||
|
permissions seeded on migration:
|
||||||
|
- Admin: all types, all scopes
|
||||||
|
- Team admin: `prompt`, `workflow` (team scope). `action` denied by default
|
||||||
|
- User: `prompt`, `workflow` (personal scope). `action` denied by default
|
||||||
|
- `system` always admin-only (hardcoded, not in permissions table)
|
||||||
|
- [ ] Create/update task handlers check permission before accepting
|
||||||
|
`task_type`. Existing `action` tasks grandfathered (can run, cannot
|
||||||
|
be cloned or created without permission). Migration adds warning
|
||||||
|
notification to owners of existing `action` tasks
|
||||||
|
- [ ] Admin UI: task permission management — per-user and per-team overrides.
|
||||||
|
"Allow action tasks for Team X" toggle. Pre-positions `starlark`
|
||||||
|
permission type (hidden until v0.29.0 enables it)
|
||||||
|
- [ ] `POST /api/v1/tasks` and `PATCH /api/v1/tasks/:id` validate
|
||||||
|
`task_type` against caller's permissions. 403 on denied type
|
||||||
|
- [ ] ICD tests: permission-denied task creation, team admin boundaries,
|
||||||
|
admin override
|
||||||
|
|
||||||
|
**ICD + docs:**
|
||||||
|
- [ ] `packages.md` ICD: install, list, enable/disable, delete, type filtering
|
||||||
|
- [ ] `EXTENSIONS.md` updated: packaging section references `.pkg` format,
|
||||||
|
loose-JS model documented as legacy
|
||||||
|
- [ ] `tasks.md` ICD updated: permission model, per-type RBAC
|
||||||
|
- [ ] ICD runner gains `packaging` test tier: install surface-type, install
|
||||||
|
extension-type, install full-type, type validation, backward compat
|
||||||
|
|
||||||
**Tier 2 — Medium value (pull into any v0.28.x):**
|
**Tier 2 — Medium value (pull into any v0.28.x):**
|
||||||
|
- [ ] User-installable extension packages: RBAC-gated `POST /packages/install`
|
||||||
|
(non-admin), team-scoped or personal scope, permission review flow.
|
||||||
|
Depends on v0.28.7 packaging.
|
||||||
- [ ] Memory compaction: summarize old memories, confidence decay, prune low-confidence
|
- [ ] Memory compaction: summarize old memories, confidence decay, prune low-confidence
|
||||||
- [ ] `capability_match` routing policy ("cheapest model with tool_calling")
|
- [ ] `capability_match` routing policy ("cheapest model with tool_calling")
|
||||||
- [ ] New provider types registrable via config file (OpenAI-compatible + custom schema)
|
- [ ] New provider types registrable via config file (OpenAI-compatible + custom schema)
|
||||||
@@ -268,7 +422,8 @@ authors consume a single coherent API instead of hunting through 15 JS files.
|
|||||||
Server-side extension runtime. Prove the eval loop, permission pipeline,
|
Server-side extension runtime. Prove the eval loop, permission pipeline,
|
||||||
and admin UI before adding capabilities.
|
and admin UI before adding capabilities.
|
||||||
|
|
||||||
Depends on: v0.28.0 (platform polish), extension infrastructure (v0.11.0).
|
Depends on: v0.28.0 (platform polish — specifically v0.28.7 unified packaging),
|
||||||
|
extension infrastructure (v0.11.0).
|
||||||
|
|
||||||
- [ ] `go.starlark.net` integration (eval loop, timeout, memory ceiling)
|
- [ ] `go.starlark.net` integration (eval loop, timeout, memory ceiling)
|
||||||
- [ ] Permission model: manifest declarations, admin grant/revoke, DB schema
|
- [ ] Permission model: manifest declarations, admin grant/revoke, DB schema
|
||||||
@@ -282,6 +437,21 @@ Depends on: v0.28.0 (platform polish), extension infrastructure (v0.11.0).
|
|||||||
(emit to users)
|
(emit to users)
|
||||||
- [ ] Migration: `extension_permissions` table
|
- [ ] Migration: `extension_permissions` table
|
||||||
- [ ] ICD: update `extensions.md` with Starlark-specific endpoints
|
- [ ] ICD: update `extensions.md` with Starlark-specific endpoints
|
||||||
|
- [ ] `task_type: "starlark"`: task executor gains Starlark code path.
|
||||||
|
Task references a `.star` script (inline or from a `.pkg`), executor
|
||||||
|
runs it in the sandbox with task context (trigger payload, schedule
|
||||||
|
info, previous run data). No LLM, no webhook relay — actual computation.
|
||||||
|
RBAC gate from v0.28.7 enforced: `task.starlark` permission enabled
|
||||||
|
in `task_permissions` (hidden until this version). Admin-grantable
|
||||||
|
to team admins on a per-team basis.
|
||||||
|
|
||||||
|
**Two-track execution model (permanent):**
|
||||||
|
- `system` (v0.28.6): Go function registry — core platform ops
|
||||||
|
(`retention_sweep`, `memory_compact`, etc.). Cannot break from
|
||||||
|
bad user code. Admin-only, not editable, not versionable.
|
||||||
|
- `starlark`: custom admin/team admin tasks — nightly reports,
|
||||||
|
data quality checks, integration sync, cleanup scripts. Editable,
|
||||||
|
versionable, sandbox-isolated, permission-gated.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -325,33 +495,192 @@ Depends on: v0.29.1 (API extensions — handlers need somewhere to persist).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.30.0 — Surface Packages
|
## v0.29.3 — Workflow Forms
|
||||||
|
|
||||||
The `.surface` archive becomes the full-stack packaging unit.
|
`form_template` renders as real UI. The LLM moves to the corner —
|
||||||
|
present if configured, but not required for data collection. Workflows
|
||||||
|
become structured processes, not guided conversations.
|
||||||
|
|
||||||
Depends on: v0.29.2 (DB extensions).
|
Depends on: v0.29.2 (DB extensions — structured form data persists in
|
||||||
|
`ext_workflow_*` tables, not just `stage_data` JSON blob), v0.29.0
|
||||||
|
(Starlark — `.star` validators run on form submission).
|
||||||
|
|
||||||
- [ ] Install creates schema + mounts routes + serves assets in one operation
|
**Form rendering:**
|
||||||
- [ ] Schema versioning: `"schema_version": N` in manifest
|
- [ ] `form_template` schema: typed field definitions (`text`, `email`,
|
||||||
- [ ] Schema migrations: `"migrations": [{"from": 1, "to": 2, "sql": "..."}]`
|
`select`, `number`, `date`, `textarea`, `checkbox`, `file`).
|
||||||
- [ ] Settings extension point: surfaces declare settings sections in manifest,
|
Validation rules per field (`required`, `pattern`, `min`/`max`).
|
||||||
platform settings surface renders them alongside core sections
|
Current freeform JSON → structured schema with backward compat
|
||||||
- [ ] Export/import format for sharing surfaces across instances
|
(old JSON treated as `textarea` field prompts for the LLM)
|
||||||
- [ ] Surface marketplace (discovery, not hosting — instances pull from URLs)
|
- [ ] Stage renders as form when `form_template` has typed fields.
|
||||||
|
HTML form generation from schema, client-side validation,
|
||||||
|
styled with platform CSS custom properties
|
||||||
|
- [ ] Form submission → `stage_data` merge (same `MergeWorkflowStageData`
|
||||||
|
path as chat-based data collection, but from structured fields
|
||||||
|
instead of LLM extraction)
|
||||||
|
- [ ] Stage advance on form submit: if `auto_transition: true` and all
|
||||||
|
required fields pass validation, advance without human operator
|
||||||
|
or LLM involvement
|
||||||
|
|
||||||
|
**LLM-optional stages:**
|
||||||
|
- [ ] Stage without `persona_id` renders form only — no chat pane, no
|
||||||
|
completion endpoint, pure data collection
|
||||||
|
- [ ] Stage with `persona_id` + `form_template` renders form + chat
|
||||||
|
side-by-side: user fills form, LLM assists (auto-fill suggestions,
|
||||||
|
field explanations, validation help)
|
||||||
|
- [ ] Stage with `persona_id` + no `form_template` — current behavior
|
||||||
|
(pure chat-based collection, no change)
|
||||||
|
|
||||||
|
**Starlark validation:**
|
||||||
|
- [ ] `validate` Starlark hook: manifest declares `.star` validator per
|
||||||
|
stage. Runs on form submit before advance. Returns field errors
|
||||||
|
or approval. Uses `db` module for cross-reference validation
|
||||||
|
(e.g., "is this email already in the system?")
|
||||||
|
- [ ] `on_submit` Starlark hook: post-validation side effects (create
|
||||||
|
records, send notifications, call APIs via `http` module)
|
||||||
|
|
||||||
|
**Visitor form entry:**
|
||||||
|
- [ ] Visitor entry point renders form for non-chat stages (branded
|
||||||
|
page with form, no chat widget). Session-scoped same as current
|
||||||
|
visitor auth model
|
||||||
|
- [ ] Progressive: visitor sees form → submits → next stage (may be
|
||||||
|
form or chat or operator review)
|
||||||
|
|
||||||
|
**Team admin UX:**
|
||||||
|
- [ ] Form builder: visual field editor in workflow admin (drag fields,
|
||||||
|
set types/validation, preview). Replaces raw JSON textarea for
|
||||||
|
`form_template`. Admin-only initially, team admin in v0.30.2.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.31.0 — Editor Surface Package
|
## v0.30.0 — Package Lifecycle
|
||||||
|
|
||||||
Rebuild the editor as an installable surface package. Zero platform
|
Lifecycle sophistication for the `.pkg` format established in v0.28.7.
|
||||||
special-casing — same `POST /admin/surfaces/install`, same manifest,
|
Schema management, settings integration, and ecosystem features.
|
||||||
same `/s/:slug` route as any third-party surface. If `pages.go` or
|
|
||||||
|
Depends on: v0.29.2 (DB extensions — schema declarations must exist before
|
||||||
|
versioning and migrations make sense).
|
||||||
|
|
||||||
|
- [ ] Install creates schema + mounts routes + serves assets in one operation
|
||||||
|
(extends v0.28.7 install with v0.29.2 schema support)
|
||||||
|
- [ ] Schema versioning: `"schema_version": N` in manifest
|
||||||
|
- [ ] Schema migrations: `"migrations": [{"from": 1, "to": 2, "sql": "..."}]`
|
||||||
|
- [ ] Settings extension point: packages declare settings sections in manifest,
|
||||||
|
platform settings surface renders them alongside core sections
|
||||||
|
- [ ] Export/import format for sharing packages across instances
|
||||||
|
- [ ] Package marketplace (discovery, not hosting — instances pull from URLs)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.30.1 — SDK Adoption
|
||||||
|
|
||||||
|
Migrate core surfaces to consume the SDK instead of raw globals.
|
||||||
|
Prerequisite for the Editor Package — if the platform's own surfaces
|
||||||
|
don't use `sw.*`, the SDK isn't validated for real.
|
||||||
|
|
||||||
|
The goal: a bug fix in `sw.notes()` or `sw.chat()` propagates to every
|
||||||
|
surface automatically. No more duplicated wiring, no more "fixed on
|
||||||
|
chat but broken on editor" class of bugs.
|
||||||
|
|
||||||
|
Depends on: v0.28.5 (SDK exists), v0.30.0 (package lifecycle — settings
|
||||||
|
extension point needed for surface-specific config).
|
||||||
|
|
||||||
|
**Component factories:**
|
||||||
|
- [ ] `sw.notes(container, opts)` — real factory: creates NoteEditor,
|
||||||
|
note list, graph panel. Extracted from `notes.js` monolith into
|
||||||
|
self-contained mountable component. `opts: { projectId, channelId,
|
||||||
|
onLink, standalone }`. Returns instance with `destroy()`.
|
||||||
|
- [ ] `sw.chat(container, opts)` — extend existing wrapper with pipe
|
||||||
|
integration (stream/render filters auto-wired), model selector
|
||||||
|
binding, file upload, typing indicator. Currently thin wrapper
|
||||||
|
around `ChatPane.create()` — needs to become the canonical path.
|
||||||
|
- [ ] `sw.panels(container, opts)` — PaneContainer composition via SDK.
|
||||||
|
Resize, tab management, drag handles. Fixes the "scaling bug lives
|
||||||
|
in two places" problem — one implementation, one fix.
|
||||||
|
|
||||||
|
**Core surface migration:**
|
||||||
|
- [ ] Chat surface (`app.js`, `chat.js`): boot via `sw = Switchboard.init()`,
|
||||||
|
replace direct `API.*` / `Events.*` / `UI.*` calls with `sw.*` in
|
||||||
|
new code paths. Existing code keeps working (globals still exist).
|
||||||
|
Progressive — not a rewrite, just new code uses the SDK.
|
||||||
|
- [ ] Editor surface (`editor-surface.js`): mount chat pane via `sw.chat()`,
|
||||||
|
mount notes via `sw.notes()`, consume `sw.theme.on('change')` for
|
||||||
|
CM6 theme sync. Removes duplicated panel resize/scale wiring.
|
||||||
|
- [ ] Notes surface: mount via `sw.notes()`, share implementation with
|
||||||
|
chat sidebar notes panel.
|
||||||
|
- [ ] Settings surface: consume `sw.api.*` and `sw.theme.*`.
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- [ ] Existing ICD runner tests still pass (surfaces behave identically)
|
||||||
|
- [ ] ICD runner `sdk` tier extended with component mount/destroy tests
|
||||||
|
- [ ] Manual: chat, editor, notes surfaces all function after migration
|
||||||
|
- [ ] Bug fix propagation test: fix applied to `sw.notes()` visible on
|
||||||
|
both chat sidebar and notes surface simultaneously
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.30.2 — Workflow Packages
|
||||||
|
|
||||||
|
Workflows become packageable. Each stage can serve its own surface —
|
||||||
|
form, dashboard, chat, or custom UI. Team admins build and manage
|
||||||
|
workflows through a visual builder, not JSON editing.
|
||||||
|
|
||||||
|
Depends on: v0.29.3 (workflow forms — form rendering exists),
|
||||||
|
v0.30.0 (package lifecycle — schema versioning, settings extension point),
|
||||||
|
v0.30.1 (SDK adoption — stage surfaces consume `sw.*`).
|
||||||
|
|
||||||
|
**Stage surfaces:**
|
||||||
|
- [ ] A workflow stage can reference a surface (built-in or from a `.pkg`).
|
||||||
|
Stage config gains `surface_id` field — platform mounts that surface
|
||||||
|
when the stage is active, with stage data injected as context
|
||||||
|
- [ ] Built-in stage surfaces: `form` (v0.29.3 form renderer), `chat`
|
||||||
|
(current behavior), `review` (operator sees collected data + approve/reject)
|
||||||
|
- [ ] Custom stage surfaces: a `.pkg` with `type: "workflow-stage"` provides
|
||||||
|
a surface that receives stage data and emits advance/reject signals
|
||||||
|
via `sw.workflow.advance(data)` / `sw.workflow.reject(reason)` SDK calls
|
||||||
|
- [ ] Stage transitions respect surface completion — surface signals "done"
|
||||||
|
with structured data, workflow engine merges and advances
|
||||||
|
|
||||||
|
**Workflow-as-package:**
|
||||||
|
- [ ] `.pkg` with `type: "workflow"`: bundles workflow definition, stage
|
||||||
|
surfaces, Starlark handlers, and assets in one installable archive.
|
||||||
|
`POST /admin/packages/install` creates the workflow + stages + surfaces
|
||||||
|
- [ ] Manifest `"workflow"` key: stages, transitions, form schemas, surface
|
||||||
|
refs, on_complete hooks. Replaces manual workflow creation via admin API
|
||||||
|
- [ ] Version pinning: installed workflow package tracks its package version.
|
||||||
|
Upgrade re-publishes with new version number
|
||||||
|
|
||||||
|
**Team admin workflow builder:**
|
||||||
|
- [ ] Visual stage editor: drag-to-reorder stages, per-stage config panel
|
||||||
|
(surface type, persona, form fields, transition rules, assignment)
|
||||||
|
- [ ] Form builder integrated per stage (extends v0.29.3 admin form builder)
|
||||||
|
- [ ] Preview: team admin can walk through the workflow as a visitor would,
|
||||||
|
seeing each stage surface in sequence
|
||||||
|
- [ ] Publish/version from the builder UI — no JSON, no API calls
|
||||||
|
- [ ] Team admin permission: `workflow.manage` — team admins can create,
|
||||||
|
edit, publish workflows for their team. Global admin can create
|
||||||
|
global workflows. Regular users can only enter workflows, not build them
|
||||||
|
|
||||||
|
**SDK extensions:**
|
||||||
|
- [ ] `sw.workflow` namespace: `advance(data)`, `reject(reason)`,
|
||||||
|
`getData()`, `getStage()`, `onTransition(fn)` — stage surfaces
|
||||||
|
interact with the workflow engine through the SDK, not raw API calls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.31.0 — Editor Package
|
||||||
|
|
||||||
|
Rebuild the editor as an installable `.pkg` package. Zero platform
|
||||||
|
special-casing — same `POST /admin/packages/install`, same manifest,
|
||||||
|
same `/s/:slug` route as any third-party package. If `pages.go` or
|
||||||
`main.go` need changes to support it, the platform abstraction is
|
`main.go` need changes to support it, the platform abstraction is
|
||||||
wrong, not the editor.
|
wrong, not the editor.
|
||||||
|
|
||||||
Validates the full v0.29.x–v0.30.0 stack E2E.
|
Validates the full v0.28.7–v0.30.2 stack E2E: unified packaging, Starlark
|
||||||
|
handlers, DB extensions, package lifecycle, SDK adoption, and workflow
|
||||||
|
packages all exercised by one real-world package.
|
||||||
|
|
||||||
Depends on: v0.30.0 (surface packages with settings extension point).
|
Depends on: v0.30.2 (workflow packages — editor validates the full stack
|
||||||
|
including workflow stage surfaces).
|
||||||
|
|
||||||
**Platform primitives consumed (not owned):**
|
**Platform primitives consumed (not owned):**
|
||||||
- CM6 (core UI primitive — chat input, notes, code blocks all use it)
|
- CM6 (core UI primitive — chat input, notes, code blocks all use it)
|
||||||
@@ -359,15 +688,15 @@ Depends on: v0.30.0 (surface packages with settings extension point).
|
|||||||
- Git tools + credentials (v0.28.0 — core settings, vault-encrypted)
|
- Git tools + credentials (v0.28.0 — core settings, vault-encrypted)
|
||||||
- Provider resolution via `requires_provider` (v0.29.1)
|
- Provider resolution via `requires_provider` (v0.29.1)
|
||||||
|
|
||||||
**Surface package delivers:**
|
**Package delivers:**
|
||||||
- [ ] Editor `.surface` archive: manifest, JS, CSS, Starlark handlers
|
- [ ] Editor `.pkg` archive (type: `full`): manifest, JS, CSS, Starlark handlers
|
||||||
- [ ] Built separately, installed via admin API — follows exact same
|
- [ ] Built separately, installed via admin API — follows exact same
|
||||||
patterns as any other surface
|
patterns as any other package
|
||||||
- [ ] Editor settings section (via v0.30.0 settings extension point):
|
- [ ] Editor settings section (via v0.30.0 settings extension point):
|
||||||
keybindings (vim/emacs/standard), font size, editor theme
|
keybindings (vim/emacs/standard), font size, editor theme
|
||||||
- [ ] Editor state persistence via `ext_editor_*` tables: open tabs,
|
- [ ] Editor state persistence via `ext_editor_*` tables: open tabs,
|
||||||
cursor positions, split layout, per-workspace config
|
cursor positions, split layout, per-workspace config
|
||||||
- [ ] File tree, tab bar, multi-pane layout — all via surface JS,
|
- [ ] File tree, tab bar, multi-pane layout — all via package JS,
|
||||||
consuming platform CSS custom properties
|
consuming platform CSS custom properties
|
||||||
- [ ] Markdown preview (improved renderer, shared with notes surface)
|
- [ ] Markdown preview (improved renderer, shared with notes surface)
|
||||||
- [ ] Remove editor from core: delete `surface-editor` template,
|
- [ ] Remove editor from core: delete `surface-editor` template,
|
||||||
|
|||||||
@@ -113,13 +113,15 @@
|
|||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/note-editor.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/note-editor.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/drag-resize.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/drag-resize.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/pane-container.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/pane-container.js?v={{.Version}}"></script>
|
||||||
|
{{/* v0.28.5: SDK — composition layer over globals. Must load after all
|
||||||
|
primitives/components it wraps, before extensions.js. */}}
|
||||||
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/switchboard-sdk.js?v={{.Version}}"></script>
|
||||||
|
|
||||||
{{/* ── Universal init: theme + appearance for ALL surfaces ── */}}
|
{{/* ── Universal init: SDK boot for ALL surfaces ── */}}
|
||||||
<script type="module" nonce="{{.CSPNonce}}">
|
<script type="module" nonce="{{.CSPNonce}}">
|
||||||
// Theme must init on every surface, not just chat.
|
// v0.28.5: SDK handles theme, appearance, and UserMenu hydration.
|
||||||
if (typeof Theme !== 'undefined') Theme.init();
|
// Surfaces that load app.js call Switchboard.init() again (idempotent).
|
||||||
// Appearance (zoom + font size) must restore on every surface.
|
Switchboard.init();
|
||||||
if (typeof UI !== 'undefined' && UI.restoreAppearance) UI.restoreAppearance();
|
|
||||||
|
|
||||||
// v0.28.0: Universal logout — available on every surface.
|
// v0.28.0: Universal logout — available on every surface.
|
||||||
// Chat/editor/notes load app.js which overrides this with a
|
// Chat/editor/notes load app.js which overrides this with a
|
||||||
@@ -145,37 +147,8 @@
|
|||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{/* ── v0.28.0: Universal UserMenu hydration ──────────────────
|
{{/* v0.28.5: UserMenu hydration moved into Switchboard.init() (switchboard-sdk.js).
|
||||||
Runs after all surface-specific scripts. Creates and binds the
|
The SDK handles create/bind/setUser for all non-chat surfaces automatically. */}}
|
||||||
UserMenu component if no surface script did it already.
|
|
||||||
|
|
||||||
Guard: chat surface excluded — it uses UI.toggleUserMenu() with
|
|
||||||
richer behavior (team admin refresh). Chat migration to UserMenu
|
|
||||||
component is tracked as tech debt (Pre-1.0 sweep).
|
|
||||||
*/}}
|
|
||||||
<script type="module" nonce="{{.CSPNonce}}">
|
|
||||||
if (typeof UserMenu === 'undefined') { /* noop */ }
|
|
||||||
else if (UserMenu.primary) { /* noop */ }
|
|
||||||
else if ((window.__SURFACE__ || '') === 'chat') { /* noop */ }
|
|
||||||
else {
|
|
||||||
var btn = document.getElementById('userMenuBtn');
|
|
||||||
if (btn) {
|
|
||||||
var menu = UserMenu.create({ id: '' });
|
|
||||||
UserMenu.primary = menu;
|
|
||||||
menu.setUser(window.__USER__ || {});
|
|
||||||
|
|
||||||
var user = window.__USER__ || {};
|
|
||||||
menu.showAdmin(user.role === 'admin');
|
|
||||||
|
|
||||||
menu.bind({
|
|
||||||
onSettings: function () { window.location.href = (window.__BASE__ || '') + '/settings'; },
|
|
||||||
onAdmin: function () { window.location.href = (window.__BASE__ || '') + '/admin'; },
|
|
||||||
onDebug: function () { sb.call('openDebugModal'); },
|
|
||||||
onSignout: function () { sb.call('handleLogout'); },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{{/* ── Debug Log Modal (all surfaces) ───── */}}
|
{{/* ── Debug Log Modal (all surfaces) ───── */}}
|
||||||
<div id="debugModal" class="modal-overlay">
|
<div id="debugModal" class="modal-overlay">
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ async function startApp() {
|
|||||||
const surface = window.__SURFACE__ || 'chat';
|
const surface = window.__SURFACE__ || 'chat';
|
||||||
|
|
||||||
// ── Common init (all surfaces) ──────────
|
// ── Common init (all surfaces) ──────────
|
||||||
|
// v0.28.5: Ensure SDK is initialized (idempotent — base.html also calls this).
|
||||||
|
if (typeof Switchboard !== 'undefined') Switchboard.init();
|
||||||
|
|
||||||
if (typeof loadSettings === 'function') await loadSettings();
|
if (typeof loadSettings === 'function') await loadSettings();
|
||||||
|
|
||||||
// Guard: if token was invalidated during startup
|
// Guard: if token was invalidated during startup
|
||||||
|
|||||||
@@ -656,6 +656,21 @@ function startRenameChat(chatId) {
|
|||||||
|
|
||||||
// ── Send Message ─────────────────────────────
|
// ── Send Message ─────────────────────────────
|
||||||
|
|
||||||
|
// v0.28.5: Build context object for pre-send pipe filters.
|
||||||
|
function _buildPreSendContext(opts) {
|
||||||
|
const chat = opts.channel || {};
|
||||||
|
return {
|
||||||
|
message: opts.message || '',
|
||||||
|
channel: { id: chat.id || null, type: chat.type || 'direct', title: chat.title || '' },
|
||||||
|
attachments: opts.attachments || [],
|
||||||
|
model: opts.model || null,
|
||||||
|
persona: opts.personaId || null,
|
||||||
|
metadata: {},
|
||||||
|
regenerate: opts.regenerate || false,
|
||||||
|
regenerateMessageId: opts.regenerateMessageId || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const text = ChatInput.getValue().trim();
|
const text = ChatInput.getValue().trim();
|
||||||
const hasFiles = hasStagedFiles();
|
const hasFiles = hasStagedFiles();
|
||||||
@@ -698,6 +713,33 @@ async function sendMessage() {
|
|||||||
const chat = App.getActiveChat();
|
const chat = App.getActiveChat();
|
||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
|
|
||||||
|
// ── v0.28.5: Pre-send pipe ──────────────────────────────
|
||||||
|
// Filters can transform message, inject metadata, or halt (return null).
|
||||||
|
if (typeof sw !== 'undefined' && sw.pipe) {
|
||||||
|
const preSendCtx = _buildPreSendContext({
|
||||||
|
message: text, channel: chat, model, personaId,
|
||||||
|
attachments: fileIds, regenerate: false,
|
||||||
|
});
|
||||||
|
const result = sw.pipe._runPre(preSendCtx);
|
||||||
|
if (result === null) {
|
||||||
|
// Filter halted — suppress message
|
||||||
|
UI.toast('Message blocked by extension', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Apply any mutations from filters — model/persona may have changed
|
||||||
|
// but message text is the most common mutation (KB inject, rewrite).
|
||||||
|
// Note: we don't overwrite `text` (const) — the API call reads from ctx.
|
||||||
|
var _preSendText = result.message;
|
||||||
|
var _preSendModel = result.model || model;
|
||||||
|
var _preSendPersonaId = result.persona || personaId;
|
||||||
|
var _preSendMeta = result.metadata || {};
|
||||||
|
} else {
|
||||||
|
var _preSendText = text;
|
||||||
|
var _preSendModel = model;
|
||||||
|
var _preSendPersonaId = personaId;
|
||||||
|
var _preSendMeta = {};
|
||||||
|
}
|
||||||
|
|
||||||
// Optimistic: show user message immediately
|
// Optimistic: show user message immediately
|
||||||
const userContent = text || (hasFiles ? `[${fileIds.length} file${fileIds.length > 1 ? 's' : ''} attached]` : '');
|
const userContent = text || (hasFiles ? `[${fileIds.length} file${fileIds.length > 1 ? 's' : ''} attached]` : '');
|
||||||
chat.messages.push({ role: 'user', content: userContent, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
|
chat.messages.push({ role: 'user', content: userContent, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
|
||||||
@@ -711,7 +753,7 @@ async function sendMessage() {
|
|||||||
UI.setGenerating(true);
|
UI.setGenerating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await API.streamCompletion(App.activeId, text, model, App.abortController.signal, modelInfo?.configId, personaId, fileIds,
|
const resp = await API.streamCompletion(App.activeId, _preSendText, _preSendModel, App.abortController.signal, modelInfo?.configId, _preSendPersonaId, fileIds,
|
||||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []);
|
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []);
|
||||||
|
|
||||||
// v0.23.2: Non-streaming response (DM with mention_only, no @mention)
|
// v0.23.2: Non-streaming response (DM with mention_only, no @mention)
|
||||||
@@ -823,6 +865,26 @@ async function regenerateMessage(messageId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.28.5: Pre-send pipe (regeneration path) ──────────
|
||||||
|
if (typeof sw !== 'undefined' && sw.pipe) {
|
||||||
|
const preSendCtx = _buildPreSendContext({
|
||||||
|
message: '', channel: chat, model, personaId,
|
||||||
|
regenerate: true, regenerateMessageId: messageId,
|
||||||
|
});
|
||||||
|
const result = sw.pipe._runPre(preSendCtx);
|
||||||
|
if (result === null) {
|
||||||
|
UI.toast('Regeneration blocked by extension', 'warning');
|
||||||
|
// Restore display since we truncated above
|
||||||
|
if (chat) UI.renderMessages(chat.messages);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var _regenModel = result.model || model;
|
||||||
|
var _regenPersonaId = result.persona || personaId;
|
||||||
|
} else {
|
||||||
|
var _regenModel = model;
|
||||||
|
var _regenPersonaId = personaId;
|
||||||
|
}
|
||||||
|
|
||||||
App.isGenerating = true;
|
App.isGenerating = true;
|
||||||
App.abortController = new AbortController();
|
App.abortController = new AbortController();
|
||||||
UI.setGenerating(true);
|
UI.setGenerating(true);
|
||||||
@@ -830,7 +892,7 @@ async function regenerateMessage(messageId) {
|
|||||||
try {
|
try {
|
||||||
const resp = await API.streamRegenerate(
|
const resp = await API.streamRegenerate(
|
||||||
App.activeId, messageId, App.abortController.signal,
|
App.activeId, messageId, App.abortController.signal,
|
||||||
model, personaId, modelInfo?.configId,
|
_regenModel, _regenPersonaId, modelInfo?.configId,
|
||||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
|
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
|
||||||
);
|
);
|
||||||
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
|
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
|
||||||
|
|||||||
@@ -348,6 +348,23 @@ const Extensions = {
|
|||||||
|
|
||||||
this._renderers.push(renderer);
|
this._renderers.push(renderer);
|
||||||
this._renderers.sort((a, b) => a.priority - b.priority);
|
this._renderers.sort((a, b) => a.priority - b.priority);
|
||||||
|
|
||||||
|
// v0.28.5: Shim 'post' renderers into the SDK render pipe.
|
||||||
|
// This makes old ctx.renderers.register() calls visible in
|
||||||
|
// sw.pipe.list() and ordered alongside new sw.pipe.render() filters.
|
||||||
|
// Block renderers stay in the old path (they run during formatMessage,
|
||||||
|
// not after DOM insertion).
|
||||||
|
if (renderer.type === 'post' && typeof sw !== 'undefined' && sw.pipe) {
|
||||||
|
sw.pipe.render(renderer.priority, (renderCtx) => {
|
||||||
|
try {
|
||||||
|
renderer.render(renderCtx.element);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Extensions] Shimmed post-renderer ${extId}:${name} error:`, e);
|
||||||
|
}
|
||||||
|
return renderCtx;
|
||||||
|
}, { source: `${extId}:${name}` });
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[Extensions] Renderer registered: ${extId}:${name} (type=${renderer.type}, priority=${renderer.priority})`);
|
console.log(`[Extensions] Renderer registered: ${extId}:${name} (type=${renderer.type}, priority=${renderer.priority})`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
491
src/js/switchboard-sdk.js
Normal file
491
src/js/switchboard-sdk.js
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Chat Switchboard — SDK (switchboard-sdk.js)
|
||||||
|
// ==========================================
|
||||||
|
// v0.28.5: Composition layer over platform globals. Surface and
|
||||||
|
// extension authors consume this instead of hunting through 15 JS files.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// const sw = Switchboard.init();
|
||||||
|
// sw.api.get('/api/v1/channels').then(console.log);
|
||||||
|
// sw.on('chat.message.*', (payload) => { ... });
|
||||||
|
// sw.pipe.render(50, (ctx) => { ... });
|
||||||
|
//
|
||||||
|
// Load order: after ui-core.js + pane-container.js, before extensions.js.
|
||||||
|
//
|
||||||
|
// Exports: window.Switchboard, window.sw (convenience alias)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const Switchboard = {
|
||||||
|
|
||||||
|
_instance: null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the SDK. Idempotent — returns the same instance on
|
||||||
|
* subsequent calls. Call early in the boot sequence (app.js init()).
|
||||||
|
*
|
||||||
|
* @param {object} [opts] — Reserved for future use ({ mount } etc.)
|
||||||
|
* @returns {object} sw — The SDK instance
|
||||||
|
*/
|
||||||
|
init(opts) {
|
||||||
|
if (this._instance) return this._instance;
|
||||||
|
|
||||||
|
const _opts = opts || {};
|
||||||
|
|
||||||
|
// ── Build the sw instance ────────────────────────────
|
||||||
|
|
||||||
|
const sw = Object.create(null);
|
||||||
|
|
||||||
|
// ── Identity ─────────────────────────────────────────
|
||||||
|
|
||||||
|
Object.defineProperty(sw, 'user', {
|
||||||
|
get() {
|
||||||
|
if (typeof API === 'undefined') return null;
|
||||||
|
const u = API.user;
|
||||||
|
if (!u) return null;
|
||||||
|
return {
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
display_name: u.display_name || u.username,
|
||||||
|
email: u.email || null,
|
||||||
|
role: u.role || 'user',
|
||||||
|
avatar: u.avatar || null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enumerable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(sw, 'isAdmin', {
|
||||||
|
get() {
|
||||||
|
return typeof API !== 'undefined' && API.isAdmin === true;
|
||||||
|
},
|
||||||
|
enumerable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── REST Client ──────────────────────────────────────
|
||||||
|
|
||||||
|
sw.api = {
|
||||||
|
/**
|
||||||
|
* GET request. Returns parsed JSON.
|
||||||
|
* @param {string} path — API path (e.g. '/api/v1/channels')
|
||||||
|
* @param {object} [opts] — { signal }
|
||||||
|
*/
|
||||||
|
async get(path, opts) {
|
||||||
|
return API._get(path, opts?.signal);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST request. Returns parsed JSON.
|
||||||
|
* @param {string} path
|
||||||
|
* @param {object} body
|
||||||
|
* @param {object} [opts] — { signal }
|
||||||
|
*/
|
||||||
|
async post(path, body, opts) {
|
||||||
|
return API._post(path, body, false, opts?.signal);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT request. Returns parsed JSON.
|
||||||
|
* @param {string} path
|
||||||
|
* @param {object} body
|
||||||
|
* @param {object} [opts] — { signal }
|
||||||
|
*/
|
||||||
|
async put(path, body, opts) {
|
||||||
|
return API._put(path, body, opts?.signal);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE request. Returns parsed JSON (or empty on 204).
|
||||||
|
* @param {string} path
|
||||||
|
* @param {object} [opts] — { signal }
|
||||||
|
*/
|
||||||
|
async del(path, opts) {
|
||||||
|
return API._delete(path, opts?.signal);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming POST. Returns raw Response for SSE consumption.
|
||||||
|
* Same contract as API.streamCompletion but generic.
|
||||||
|
* @param {string} path
|
||||||
|
* @param {object} body
|
||||||
|
* @param {AbortSignal} [signal]
|
||||||
|
*/
|
||||||
|
async stream(path, body, signal) {
|
||||||
|
const BASE = window.__BASE__ || '';
|
||||||
|
let resp = await fetch(BASE + path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: API._authHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (resp.status === 401) {
|
||||||
|
if (await API._handle401()) {
|
||||||
|
resp = await fetch(BASE + path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: API._authHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Events ───────────────────────────────────────────
|
||||||
|
|
||||||
|
sw.on = function (label, fn) {
|
||||||
|
return Events.on(label, fn);
|
||||||
|
};
|
||||||
|
|
||||||
|
sw.once = function (label, fn) {
|
||||||
|
return Events.once(label, fn);
|
||||||
|
};
|
||||||
|
|
||||||
|
sw.off = function (label, fn) {
|
||||||
|
Events.off(label, fn);
|
||||||
|
};
|
||||||
|
|
||||||
|
sw.emit = function (label, payload, opts) {
|
||||||
|
Events.emit(label, payload, opts);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Theme ────────────────────────────────────────────
|
||||||
|
|
||||||
|
sw.theme = {
|
||||||
|
/** Resolved theme: 'dark' or 'light' (never 'system'). */
|
||||||
|
get current() {
|
||||||
|
if (typeof Theme !== 'undefined') return Theme.resolved();
|
||||||
|
return 'dark';
|
||||||
|
},
|
||||||
|
|
||||||
|
/** User preference: 'dark', 'light', or 'system'. */
|
||||||
|
get mode() {
|
||||||
|
if (typeof Theme !== 'undefined') return Theme.get();
|
||||||
|
return 'system';
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Set theme mode. */
|
||||||
|
set(mode) {
|
||||||
|
if (typeof Theme !== 'undefined') Theme.set(mode);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to theme changes.
|
||||||
|
* @param {string} event — 'change'
|
||||||
|
* @param {Function} fn — receives resolved theme string
|
||||||
|
* @returns {Function} unsubscribe
|
||||||
|
*/
|
||||||
|
on(event, fn) {
|
||||||
|
if (event === 'change') {
|
||||||
|
return Events.on('theme.changed', (payload) => {
|
||||||
|
const resolved = typeof Theme !== 'undefined'
|
||||||
|
? Theme.resolved()
|
||||||
|
: 'dark';
|
||||||
|
fn(resolved);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.warn(`[Switchboard] theme.on: unknown event '${event}'`);
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── UI Primitives ────────────────────────────────────
|
||||||
|
|
||||||
|
sw.toast = function (message, type) {
|
||||||
|
if (typeof UI !== 'undefined' && UI.toast) {
|
||||||
|
UI.toast(message, type || 'success');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sw.confirm = function (message, opts) {
|
||||||
|
if (typeof showConfirm === 'function') return showConfirm(message, opts);
|
||||||
|
return Promise.resolve(window.confirm(message));
|
||||||
|
};
|
||||||
|
|
||||||
|
sw.modal = {
|
||||||
|
open(contentOrId) {
|
||||||
|
if (typeof openModal === 'function') openModal(contentOrId);
|
||||||
|
},
|
||||||
|
close(id) {
|
||||||
|
if (typeof closeModal === 'function') closeModal(id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Components ───────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a ChatPane instance in a container element.
|
||||||
|
* Wraps ChatPane.create() with SDK-aware defaults.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} container — mount target
|
||||||
|
* @param {object} opts — { channelId, standalone }
|
||||||
|
* @returns {object} ChatPane instance (renderMessages, destroy, etc.)
|
||||||
|
*/
|
||||||
|
sw.chat = function (container, opts) {
|
||||||
|
if (typeof ChatPane === 'undefined') {
|
||||||
|
console.error('[Switchboard] ChatPane not available');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const _opts = opts || {};
|
||||||
|
// Find or create required child elements
|
||||||
|
let messagesEl = container.querySelector('.sw-chat-messages');
|
||||||
|
let inputEl = container.querySelector('.sw-chat-input');
|
||||||
|
if (!messagesEl) {
|
||||||
|
messagesEl = document.createElement('div');
|
||||||
|
messagesEl.className = 'sw-chat-messages chat-messages';
|
||||||
|
container.appendChild(messagesEl);
|
||||||
|
}
|
||||||
|
if (!inputEl) {
|
||||||
|
inputEl = document.createElement('div');
|
||||||
|
inputEl.className = 'sw-chat-input';
|
||||||
|
container.appendChild(inputEl);
|
||||||
|
}
|
||||||
|
return ChatPane.create({
|
||||||
|
messagesEl,
|
||||||
|
inputEl,
|
||||||
|
channelId: _opts.channelId || null,
|
||||||
|
standalone: _opts.standalone !== false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize notes in a container element.
|
||||||
|
* NOTE: v0.28.5 stub — notes lacks a clean create() factory.
|
||||||
|
* Full component extraction is pre-1.0 tech debt.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
* @param {object} opts — { projectId }
|
||||||
|
*/
|
||||||
|
sw.notes = function (container, opts) {
|
||||||
|
console.warn('[Switchboard] sw.notes() not yet available — notes component needs refactor (pre-1.0)');
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Pipe/Filter Pipeline ─────────────────────────────
|
||||||
|
// CS1: registration + introspection. CS2: execution engine
|
||||||
|
// wired into chat.js, ui-core.js, ui-format.js.
|
||||||
|
|
||||||
|
const _chains = {
|
||||||
|
pre: [], // { priority, fn, scope, source, stats }
|
||||||
|
stream: [],
|
||||||
|
render: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a filter into a pipe stage.
|
||||||
|
* @param {string} stage — 'pre' | 'stream' | 'render'
|
||||||
|
* @param {number} priority — lower runs first
|
||||||
|
* @param {Function} fn — filter function
|
||||||
|
* @param {object} [opts] — { scope: { channelType: [...] }, source: string }
|
||||||
|
*/
|
||||||
|
function _registerFilter(stage, priority, fn, opts) {
|
||||||
|
if (typeof fn !== 'function') {
|
||||||
|
console.error(`[Switchboard] pipe.${stage}: filter must be a function`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chain = _chains[stage];
|
||||||
|
if (!chain) {
|
||||||
|
console.error(`[Switchboard] pipe.${stage}: unknown stage`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const _opts = opts || {};
|
||||||
|
const entry = {
|
||||||
|
priority: priority,
|
||||||
|
fn: fn,
|
||||||
|
scope: _opts.scope || null,
|
||||||
|
source: _opts.source || _inferSource(),
|
||||||
|
stats: { calls: 0, totalMs: 0, errors: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for duplicate (same source + priority) — warn but allow
|
||||||
|
const dup = chain.find(e => e.source === entry.source && e.priority === entry.priority);
|
||||||
|
if (dup) {
|
||||||
|
console.warn(`[Switchboard] pipe.${stage}: duplicate registration (source=${entry.source}, priority=${priority})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
chain.push(entry);
|
||||||
|
chain.sort((a, b) => a.priority - b.priority || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer the source label from the call stack.
|
||||||
|
* Tries to extract extension ID from the script path.
|
||||||
|
*/
|
||||||
|
function _inferSource() {
|
||||||
|
try {
|
||||||
|
const stack = new Error().stack || '';
|
||||||
|
// Look for extensions/{id}/ or ext:: patterns
|
||||||
|
const extMatch = stack.match(/extensions\/([^/]+)\//);
|
||||||
|
if (extMatch) return extMatch[1];
|
||||||
|
} catch (_) { /* best effort */ }
|
||||||
|
return 'anonymous';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a filter chain against a context. Returns modified context or null.
|
||||||
|
* Sync-only in v0.28.5 — filters must not return Promises.
|
||||||
|
*
|
||||||
|
* @param {string} stage — chain name
|
||||||
|
* @param {object} ctx — context object (mutated in place)
|
||||||
|
* @returns {object|null} — modified context or null (halted)
|
||||||
|
*/
|
||||||
|
function _runChain(stage, ctx) {
|
||||||
|
const chain = _chains[stage];
|
||||||
|
if (!chain || chain.length === 0) return ctx;
|
||||||
|
|
||||||
|
const channelType = ctx.channel?.type || null;
|
||||||
|
|
||||||
|
for (const entry of chain) {
|
||||||
|
// Scope check: skip if filter's channelType list doesn't include this channel
|
||||||
|
if (entry.scope?.channelType) {
|
||||||
|
if (!channelType || !entry.scope.channelType.includes(channelType)) {
|
||||||
|
continue; // scoped out — zero overhead, no call, no stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const t0 = performance.now();
|
||||||
|
try {
|
||||||
|
const result = entry.fn(ctx);
|
||||||
|
entry.stats.calls++;
|
||||||
|
entry.stats.totalMs += performance.now() - t0;
|
||||||
|
|
||||||
|
if (result === null || result === undefined) {
|
||||||
|
// Halt the chain
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ctx = result;
|
||||||
|
} catch (e) {
|
||||||
|
entry.stats.calls++;
|
||||||
|
entry.stats.errors++;
|
||||||
|
entry.stats.totalMs += performance.now() - t0;
|
||||||
|
console.error(`[Switchboard] pipe.${stage} filter '${entry.source}' (p=${entry.priority}) threw:`, e);
|
||||||
|
// Continue chain — error isolation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.pipe = {
|
||||||
|
/**
|
||||||
|
* Register a pre-send filter.
|
||||||
|
* @param {number} priority
|
||||||
|
* @param {Function} fn — (PreSendContext) => PreSendContext | null
|
||||||
|
* @param {object} [opts] — { scope, source }
|
||||||
|
*/
|
||||||
|
pre(priority, fn, opts) {
|
||||||
|
_registerFilter('pre', priority, fn, opts);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a post-receive stream filter.
|
||||||
|
* @param {number} priority
|
||||||
|
* @param {Function} fn — (StreamContext) => StreamContext | null
|
||||||
|
* @param {object} [opts] — { scope, source }
|
||||||
|
*/
|
||||||
|
stream(priority, fn, opts) {
|
||||||
|
_registerFilter('stream', priority, fn, opts);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a post-render filter.
|
||||||
|
* @param {number} priority
|
||||||
|
* @param {Function} fn — (RenderContext) => RenderContext | null
|
||||||
|
* @param {object} [opts] — { scope, source }
|
||||||
|
*/
|
||||||
|
render(priority, fn, opts) {
|
||||||
|
_registerFilter('render', priority, fn, opts);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all registered filters with stats.
|
||||||
|
* @returns {object} { pre: [...], stream: [...], render: [...] }
|
||||||
|
*/
|
||||||
|
list() {
|
||||||
|
const result = {};
|
||||||
|
for (const [stage, chain] of Object.entries(_chains)) {
|
||||||
|
result[stage] = chain.map(e => ({
|
||||||
|
priority: e.priority,
|
||||||
|
source: e.source,
|
||||||
|
scope: e.scope,
|
||||||
|
calls: e.stats.calls,
|
||||||
|
avgMs: e.stats.calls > 0
|
||||||
|
? Math.round((e.stats.totalMs / e.stats.calls) * 100) / 100
|
||||||
|
: 0,
|
||||||
|
errors: e.stats.errors,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Internal: called by patched chat.js / ui-core.js / ui-format.js ──
|
||||||
|
|
||||||
|
/** Run pre-send chain. Returns modified context or null. */
|
||||||
|
_runPre(ctx) { return _runChain('pre', ctx); },
|
||||||
|
|
||||||
|
/** Run stream chain. Returns modified context or null. */
|
||||||
|
_runStream(ctx) { return _runChain('stream', ctx); },
|
||||||
|
|
||||||
|
/** Run render chain. Returns modified context or null. */
|
||||||
|
_runRender(ctx) { return _runChain('render', ctx); },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── UserMenu Hydration ───────────────────────────────
|
||||||
|
// Absorbs the cs15 band-aid from base.html. Every surface
|
||||||
|
// gets UserMenu wired up through the SDK, not through an
|
||||||
|
// inline <script> block that duplicates logic.
|
||||||
|
|
||||||
|
function _hydrateUserMenu() {
|
||||||
|
if (typeof UserMenu === 'undefined') return;
|
||||||
|
if (UserMenu.primary) return; // already created by a surface
|
||||||
|
|
||||||
|
// Chat surface manages its own UserMenu via UI.toggleUserMenu()
|
||||||
|
const surface = window.__SURFACE__ || '';
|
||||||
|
if (surface === 'chat') return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('userMenuBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const menu = UserMenu.create({ id: '' });
|
||||||
|
UserMenu.primary = menu;
|
||||||
|
|
||||||
|
const user = window.__USER__ || {};
|
||||||
|
menu.setUser(user);
|
||||||
|
menu.showAdmin(user.role === 'admin');
|
||||||
|
|
||||||
|
const BASE = window.__BASE__ || '';
|
||||||
|
menu.bind({
|
||||||
|
onSettings: () => { window.location.href = BASE + '/settings'; },
|
||||||
|
onAdmin: () => { window.location.href = BASE + '/admin'; },
|
||||||
|
onDebug: () => { sb.call('openDebugModal'); },
|
||||||
|
onSignout: () => { sb.call('handleLogout'); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Perform Init ─────────────────────────────────────
|
||||||
|
|
||||||
|
// Theme + appearance (safe on all surfaces)
|
||||||
|
if (typeof Theme !== 'undefined') Theme.init();
|
||||||
|
if (typeof UI !== 'undefined' && UI.restoreAppearance) UI.restoreAppearance();
|
||||||
|
|
||||||
|
// UserMenu hydration
|
||||||
|
_hydrateUserMenu();
|
||||||
|
|
||||||
|
// Emit ready event
|
||||||
|
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
|
||||||
|
|
||||||
|
this._instance = sw;
|
||||||
|
window.sw = sw; // convenience alias
|
||||||
|
|
||||||
|
console.log('[Switchboard] SDK initialized');
|
||||||
|
return sw;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Registration ─────────────────────────────────────────────
|
||||||
|
sb.ns('Switchboard', Switchboard);
|
||||||
@@ -1124,7 +1124,31 @@ const UI = {
|
|||||||
// Normal content delta
|
// Normal content delta
|
||||||
const delta = parsed.choices?.[0]?.delta?.content || '';
|
const delta = parsed.choices?.[0]?.delta?.content || '';
|
||||||
if (delta) {
|
if (delta) {
|
||||||
|
// ── v0.28.5: Stream pipe ────────────────────
|
||||||
|
if (typeof sw !== 'undefined' && sw.pipe) {
|
||||||
|
const streamCtx = {
|
||||||
|
chunk: delta,
|
||||||
|
accumulated: content + delta,
|
||||||
|
channel: {
|
||||||
|
id: App.activeId || null,
|
||||||
|
type: App.activeType || 'direct',
|
||||||
|
},
|
||||||
|
model: App.settings?.model || '',
|
||||||
|
event: currentEvent || 'content',
|
||||||
|
tokens: { input: 0, output: content.length + delta.length },
|
||||||
|
};
|
||||||
|
const result = sw.pipe._runStream(streamCtx);
|
||||||
|
if (result === null) {
|
||||||
|
// Chunk dropped by filter
|
||||||
|
currentEvent = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Use filter's accumulated (may differ from simple append)
|
||||||
|
content = result.accumulated;
|
||||||
|
} else {
|
||||||
content += delta;
|
content += delta;
|
||||||
|
}
|
||||||
|
|
||||||
const full = (reasoning ? '<think>' + reasoning + '</think>' : '') + content;
|
const full = (reasoning ? '<think>' + reasoning + '</think>' : '') + content;
|
||||||
if (activeContentEl) activeContentEl.innerHTML = formatMessage(full);
|
if (activeContentEl) activeContentEl.innerHTML = formatMessage(full);
|
||||||
this._scrollToBottom();
|
this._scrollToBottom();
|
||||||
|
|||||||
@@ -577,6 +577,22 @@ function _relativeTime(dateStr) {
|
|||||||
* Safe to call even if extensions aren't loaded — no-ops gracefully.
|
* Safe to call even if extensions aren't loaded — no-ops gracefully.
|
||||||
*/
|
*/
|
||||||
function runExtensionPostRender(container) {
|
function runExtensionPostRender(container) {
|
||||||
|
// v0.28.5: Render pipe subsumes extension post-renderers.
|
||||||
|
// The pipe includes compat-shimmed renderers from ctx.renderers.register()
|
||||||
|
// alongside new sw.pipe.render() filters.
|
||||||
|
if (typeof sw !== 'undefined' && sw.pipe) {
|
||||||
|
const renderCtx = {
|
||||||
|
element: container,
|
||||||
|
message: null, // batch render — no single message context
|
||||||
|
channel: {
|
||||||
|
id: typeof App !== 'undefined' ? App.activeId : null,
|
||||||
|
type: typeof App !== 'undefined' ? (App.activeType || 'direct') : 'direct',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
sw.pipe._runRender(renderCtx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fallback: SDK not loaded (shouldn't happen, but defensive)
|
||||||
if (typeof Extensions !== 'undefined' && Extensions._loaded) {
|
if (typeof Extensions !== 'undefined' && Extensions._loaded) {
|
||||||
Extensions.runPostRenderers(container);
|
Extensions.runPostRenderers(container);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
surfaces/icd-test-runner.surface
Normal file
BIN
surfaces/icd-test-runner.surface
Normal file
Binary file not shown.
@@ -201,15 +201,31 @@
|
|||||||
try {
|
try {
|
||||||
await fn();
|
await fn();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e && e._skip) {
|
||||||
|
entry.status = 'skip';
|
||||||
|
entry.detail = String(e.message || 'skipped');
|
||||||
|
} else {
|
||||||
entry.status = 'fail';
|
entry.status = 'fail';
|
||||||
entry.detail = String(e && e.message ? e.message : e);
|
entry.detail = String(e && e.message ? e.message : e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
entry.duration = Math.round(performance.now() - t0);
|
entry.duration = Math.round(performance.now() - t0);
|
||||||
T.results.push(entry);
|
T.results.push(entry);
|
||||||
if (typeof T.renderProgress === 'function') T.renderProgress();
|
if (typeof T.renderProgress === 'function') T.renderProgress();
|
||||||
return entry;
|
return entry;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip a test with a reason. Call inside a T.test() fn body.
|
||||||
|
* Skipped tests appear in results as status='skip' — not pass, not fail.
|
||||||
|
* @param {string} reason — why this test was skipped
|
||||||
|
*/
|
||||||
|
T.skip = function (reason) {
|
||||||
|
var e = new Error(reason || 'skipped');
|
||||||
|
e._skip = true;
|
||||||
|
throw e;
|
||||||
|
};
|
||||||
|
|
||||||
// ─── API Wrappers ───────────────────────────────────────────
|
// ─── API Wrappers ───────────────────────────────────────────
|
||||||
// API._get/_post/_put already prepend __BASE__. No base prefix here.
|
// API._get/_post/_put already prepend __BASE__. No base prefix here.
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,7 @@
|
|||||||
'tier-authz.js',
|
'tier-authz.js',
|
||||||
'tier-security.js',
|
'tier-security.js',
|
||||||
'tier-providers.js',
|
'tier-providers.js',
|
||||||
|
'tier-sdk.js',
|
||||||
'ui.js'
|
'ui.js'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
501
surfaces/icd-test-runner/js/tier-sdk.js
Normal file
501
surfaces/icd-test-runner/js/tier-sdk.js
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
/**
|
||||||
|
* ICD Test Runner — SDK Tier
|
||||||
|
* Validates switchboard-sdk.js contract: boot, identity, REST client,
|
||||||
|
* events, theme, pipe registration, execution ordering, scoping,
|
||||||
|
* halt semantics, error isolation, extension compat shim, introspection.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var T = window.ICD;
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Create a temporary direct channel for pipe tests. Returns channel object. */
|
||||||
|
async function _createTestChannel(tag) {
|
||||||
|
var title = 'sdk-test-' + tag + '-' + Date.now();
|
||||||
|
var ch = await T.apiPost('/channels', { title: title, type: 'direct' });
|
||||||
|
T.cleanup.push(function () { return T.apiDelete('/channels/' + ch.id).catch(function () {}); });
|
||||||
|
return ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all SDK pipe filters between tests.
|
||||||
|
* The SDK stores filters in closure-scoped arrays; we reach them
|
||||||
|
* through sw.pipe.list() and re-init by registering a clear flag.
|
||||||
|
* In practice we just note that tests accumulate filters — ordering
|
||||||
|
* tests carefully so later tests expect earlier filters to exist.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Tier ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
T.runSdk = async function () {
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// BOOT
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('sdk', 'boot', 'Switchboard.init() returns sw object', async function () {
|
||||||
|
T.assert(typeof Switchboard !== 'undefined', 'Switchboard global missing');
|
||||||
|
var inst = Switchboard.init();
|
||||||
|
T.assert(inst !== null && typeof inst === 'object', 'init() should return object');
|
||||||
|
T.assert(inst === window.sw, 'window.sw should be the same instance');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'boot', 'Double init returns same instance', async function () {
|
||||||
|
var a = Switchboard.init();
|
||||||
|
var b = Switchboard.init();
|
||||||
|
T.assert(a === b, 'idempotent: must return same object');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// IDENTITY
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('sdk', 'identity', 'sw.user populated', async function () {
|
||||||
|
T.assert(sw.user !== null, 'sw.user should not be null');
|
||||||
|
T.assert(typeof sw.user.id === 'string' && sw.user.id.length > 0, 'user.id');
|
||||||
|
T.assert(typeof sw.user.username === 'string', 'user.username');
|
||||||
|
T.assert(typeof sw.user.role === 'string', 'user.role');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'identity', 'sw.isAdmin reflects role', async function () {
|
||||||
|
T.assert(typeof sw.isAdmin === 'boolean', 'isAdmin should be boolean');
|
||||||
|
T.assert(sw.isAdmin === (sw.user.role === 'admin'), 'isAdmin should match role');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// REST CLIENT
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('sdk', 'api', 'sw.api.get /health', async function () {
|
||||||
|
var d = await sw.api.get('/api/v1/health');
|
||||||
|
T.assert(d.status === 'ok', 'health status should be ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'api', 'sw.api.get /channels returns envelope', async function () {
|
||||||
|
var d = await sw.api.get('/api/v1/channels');
|
||||||
|
T.assertHasKey(d, 'data', '/channels');
|
||||||
|
T.assert(Array.isArray(d.data), 'data should be array');
|
||||||
|
});
|
||||||
|
|
||||||
|
var _sdkTestChannel = null;
|
||||||
|
|
||||||
|
await T.test('sdk', 'api', 'sw.api.post creates channel', async function () {
|
||||||
|
var d = await sw.api.post('/api/v1/channels', {
|
||||||
|
title: 'sdk-api-test-' + Date.now(), type: 'direct'
|
||||||
|
});
|
||||||
|
T.assert(typeof d.id === 'string', 'created channel should have id');
|
||||||
|
_sdkTestChannel = d;
|
||||||
|
T.cleanup.push(function () { return T.apiDelete('/channels/' + d.id).catch(function () {}); });
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'api', 'sw.api.del removes channel', async function () {
|
||||||
|
if (!_sdkTestChannel) throw new Error('no channel from previous test');
|
||||||
|
// Create a throwaway channel to delete
|
||||||
|
var d = await sw.api.post('/api/v1/channels', {
|
||||||
|
title: 'sdk-del-test-' + Date.now(), type: 'direct'
|
||||||
|
});
|
||||||
|
await sw.api.del('/api/v1/channels/' + d.id);
|
||||||
|
// Verify it's gone
|
||||||
|
try {
|
||||||
|
await sw.api.get('/api/v1/channels/' + d.id);
|
||||||
|
throw new Error('channel should be deleted');
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message === 'channel should be deleted') throw e;
|
||||||
|
// Expected: 404 or error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// EVENTS
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('sdk', 'events', 'sw.on receives sw.emit', async function () {
|
||||||
|
var received = null;
|
||||||
|
var unsub = sw.on('sdk.test.ping', function (payload) { received = payload; });
|
||||||
|
sw.emit('sdk.test.ping', { v: 42 }, { localOnly: true });
|
||||||
|
T.assert(received !== null, 'handler should have fired');
|
||||||
|
T.assert(received.v === 42, 'payload should match');
|
||||||
|
unsub();
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'events', 'sw.once fires exactly once', async function () {
|
||||||
|
var count = 0;
|
||||||
|
sw.once('sdk.test.once', function () { count++; });
|
||||||
|
sw.emit('sdk.test.once', {}, { localOnly: true });
|
||||||
|
sw.emit('sdk.test.once', {}, { localOnly: true });
|
||||||
|
T.assert(count === 1, 'expected 1, got ' + count);
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'events', 'sw.off stops delivery', async function () {
|
||||||
|
var count = 0;
|
||||||
|
var fn = function () { count++; };
|
||||||
|
sw.on('sdk.test.off', fn);
|
||||||
|
sw.emit('sdk.test.off', {}, { localOnly: true });
|
||||||
|
T.assert(count === 1, 'should fire once before off');
|
||||||
|
sw.off('sdk.test.off', fn);
|
||||||
|
sw.emit('sdk.test.off', {}, { localOnly: true });
|
||||||
|
T.assert(count === 1, 'should not fire after off');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// THEME
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('sdk', 'theme', 'sw.theme.current returns dark or light', async function () {
|
||||||
|
var c = sw.theme.current;
|
||||||
|
T.assert(c === 'dark' || c === 'light', 'expected dark|light, got ' + c);
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'theme', 'sw.theme.mode returns preference', async function () {
|
||||||
|
var m = sw.theme.mode;
|
||||||
|
T.assert(m === 'dark' || m === 'light' || m === 'system', 'expected dark|light|system, got ' + m);
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'theme', 'sw.theme.on change fires', async function () {
|
||||||
|
var fired = false;
|
||||||
|
var unsub = sw.theme.on('change', function (resolved) {
|
||||||
|
fired = true;
|
||||||
|
T.assert(resolved === 'dark' || resolved === 'light', 'resolved should be dark|light');
|
||||||
|
});
|
||||||
|
// Trigger a theme change event
|
||||||
|
Events.emit('theme.changed', {}, { localOnly: true });
|
||||||
|
T.assert(fired, 'change handler should have fired');
|
||||||
|
unsub();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// PIPE REGISTRATION
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('sdk', 'pipe', 'sw.pipe.pre registers', async function () {
|
||||||
|
sw.pipe.pre(100, function (ctx) { return ctx; }, { source: '_test-pre' });
|
||||||
|
var list = sw.pipe.list();
|
||||||
|
var found = list.pre.some(function (f) { return f.source === '_test-pre'; });
|
||||||
|
T.assert(found, '_test-pre should appear in pipe.list().pre');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'pipe', 'sw.pipe.stream registers', async function () {
|
||||||
|
sw.pipe.stream(100, function (ctx) { return ctx; }, { source: '_test-stream' });
|
||||||
|
var list = sw.pipe.list();
|
||||||
|
var found = list.stream.some(function (f) { return f.source === '_test-stream'; });
|
||||||
|
T.assert(found, '_test-stream should appear in pipe.list().stream');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'pipe', 'sw.pipe.render registers', async function () {
|
||||||
|
sw.pipe.render(100, function (ctx) { return ctx; }, { source: '_test-render' });
|
||||||
|
var list = sw.pipe.list();
|
||||||
|
var found = list.render.some(function (f) { return f.source === '_test-render'; });
|
||||||
|
T.assert(found, '_test-render should appear in pipe.list().render');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'pipe', 'Scoped filter registers with scope', async function () {
|
||||||
|
sw.pipe.pre(101, function (ctx) { return ctx; }, {
|
||||||
|
source: '_test-scoped',
|
||||||
|
scope: { channelType: ['workflow'] }
|
||||||
|
});
|
||||||
|
var list = sw.pipe.list();
|
||||||
|
var entry = list.pre.find(function (f) { return f.source === '_test-scoped'; });
|
||||||
|
T.assert(entry !== undefined, 'scoped filter should be in list');
|
||||||
|
T.assert(entry.scope !== null, 'scope should be set');
|
||||||
|
T.assert(Array.isArray(entry.scope.channelType), 'scope.channelType should be array');
|
||||||
|
T.assert(entry.scope.channelType[0] === 'workflow', 'should be scoped to workflow');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'pipe', 'Priority ordering', async function () {
|
||||||
|
sw.pipe.render(5, function (ctx) { return ctx; }, { source: '_test-priority-5' });
|
||||||
|
sw.pipe.render(99, function (ctx) { return ctx; }, { source: '_test-priority-99' });
|
||||||
|
var list = sw.pipe.list();
|
||||||
|
var idx5 = -1, idx99 = -1;
|
||||||
|
for (var i = 0; i < list.render.length; i++) {
|
||||||
|
if (list.render[i].source === '_test-priority-5') idx5 = i;
|
||||||
|
if (list.render[i].source === '_test-priority-99') idx99 = i;
|
||||||
|
}
|
||||||
|
T.assert(idx5 >= 0 && idx99 >= 0, 'both filters should exist');
|
||||||
|
T.assert(idx5 < idx99, 'priority 5 should come before priority 99');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// PIPE EXECUTION — PRE-SEND
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('sdk', 'pipe-pre', 'Filter mutates context', async function () {
|
||||||
|
sw.pipe.pre(1, function (ctx) {
|
||||||
|
ctx.metadata.sdk_test = 'injected';
|
||||||
|
return ctx;
|
||||||
|
}, { source: '_test-mutate' });
|
||||||
|
|
||||||
|
// Simulate _runPre with a fake context
|
||||||
|
var ctx = {
|
||||||
|
message: 'hello', channel: { id: 'fake', type: 'direct', title: '' },
|
||||||
|
attachments: [], model: 'test', persona: null,
|
||||||
|
metadata: {}, regenerate: false, regenerateMessageId: null
|
||||||
|
};
|
||||||
|
var result = sw.pipe._runPre(ctx);
|
||||||
|
T.assert(result !== null, 'should not halt');
|
||||||
|
T.assert(result.metadata.sdk_test === 'injected', 'metadata should be mutated');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'pipe-pre', 'Halt filter returns null', async function () {
|
||||||
|
sw.pipe.pre(0, function (ctx) {
|
||||||
|
if (ctx.metadata._halt_test) return null;
|
||||||
|
return ctx;
|
||||||
|
}, { source: '_test-halt' });
|
||||||
|
|
||||||
|
var ctx = {
|
||||||
|
message: 'halt me', channel: { id: 'fake', type: 'direct', title: '' },
|
||||||
|
attachments: [], model: 'test', persona: null,
|
||||||
|
metadata: { _halt_test: true }, regenerate: false, regenerateMessageId: null
|
||||||
|
};
|
||||||
|
var result = sw.pipe._runPre(ctx);
|
||||||
|
T.assert(result === null, 'chain should be halted');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'pipe-pre', 'Error isolation — filter throws, chain continues', async function () {
|
||||||
|
sw.pipe.pre(2, function (ctx) {
|
||||||
|
if (ctx.metadata._throw_test) throw new Error('intentional');
|
||||||
|
return ctx;
|
||||||
|
}, { source: '_test-throw' });
|
||||||
|
|
||||||
|
sw.pipe.pre(3, function (ctx) {
|
||||||
|
ctx.metadata.after_throw = true;
|
||||||
|
return ctx;
|
||||||
|
}, { source: '_test-after-throw' });
|
||||||
|
|
||||||
|
var ctx = {
|
||||||
|
message: 'test', channel: { id: 'fake', type: 'direct', title: '' },
|
||||||
|
attachments: [], model: 'test', persona: null,
|
||||||
|
metadata: { _throw_test: true }, regenerate: false, regenerateMessageId: null
|
||||||
|
};
|
||||||
|
var result = sw.pipe._runPre(ctx);
|
||||||
|
T.assert(result !== null, 'chain should not halt on throw');
|
||||||
|
T.assert(result.metadata.after_throw === true, 'subsequent filter should have run');
|
||||||
|
|
||||||
|
// Verify error was counted
|
||||||
|
var list = sw.pipe.list();
|
||||||
|
var entry = list.pre.find(function (f) { return f.source === '_test-throw'; });
|
||||||
|
T.assert(entry && entry.errors > 0, 'error count should be > 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// PIPE EXECUTION — STREAM
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('sdk', 'pipe-stream', 'Stream filter counts chunks', async function () {
|
||||||
|
var counter = 0;
|
||||||
|
sw.pipe.stream(1, function (ctx) {
|
||||||
|
if (ctx.channel.id === '_stream_test') counter++;
|
||||||
|
return ctx;
|
||||||
|
}, { source: '_test-stream-counter' });
|
||||||
|
|
||||||
|
// Simulate 3 chunks
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
sw.pipe._runStream({
|
||||||
|
chunk: 'word' + i + ' ', accumulated: '',
|
||||||
|
channel: { id: '_stream_test', type: 'direct' },
|
||||||
|
model: 'test', event: 'content', tokens: { input: 0, output: 0 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
T.assert(counter === 3, 'expected 3 chunk calls, got ' + counter);
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'pipe-stream', 'Stream filter modifies accumulated', async function () {
|
||||||
|
sw.pipe.stream(2, function (ctx) {
|
||||||
|
if (ctx.channel.id === '_accum_test') {
|
||||||
|
ctx.accumulated = ctx.accumulated.toUpperCase();
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}, { source: '_test-stream-accum' });
|
||||||
|
|
||||||
|
var result = sw.pipe._runStream({
|
||||||
|
chunk: 'hello', accumulated: 'hello world',
|
||||||
|
channel: { id: '_accum_test', type: 'direct' },
|
||||||
|
model: 'test', event: 'content', tokens: { input: 0, output: 0 }
|
||||||
|
});
|
||||||
|
T.assert(result.accumulated === 'HELLO WORLD', 'accumulated should be uppercased');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'pipe-stream', 'Null drops chunk', async function () {
|
||||||
|
sw.pipe.stream(0, function (ctx) {
|
||||||
|
if (ctx.channel.id === '_drop_test') return null;
|
||||||
|
return ctx;
|
||||||
|
}, { source: '_test-stream-drop' });
|
||||||
|
|
||||||
|
var result = sw.pipe._runStream({
|
||||||
|
chunk: 'x', accumulated: 'x',
|
||||||
|
channel: { id: '_drop_test', type: 'direct' },
|
||||||
|
model: 'test', event: 'content', tokens: { input: 0, output: 0 }
|
||||||
|
});
|
||||||
|
T.assert(result === null, 'should return null for dropped chunk');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// PIPE EXECUTION — RENDER
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('sdk', 'pipe-render', 'Render filter modifies DOM', async function () {
|
||||||
|
sw.pipe.render(1, function (ctx) {
|
||||||
|
if (ctx.element.dataset.sdkRenderTest) {
|
||||||
|
ctx.element.classList.add('sdk-render-modified');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}, { source: '_test-render-dom' });
|
||||||
|
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.dataset.sdkRenderTest = 'true';
|
||||||
|
sw.pipe._runRender({
|
||||||
|
element: el, message: null,
|
||||||
|
channel: { id: 'fake', type: 'direct' }
|
||||||
|
});
|
||||||
|
T.assert(el.classList.contains('sdk-render-modified'), 'element should have class');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'pipe-render', 'Priority ordering in render chain', async function () {
|
||||||
|
var order = [];
|
||||||
|
sw.pipe.render(10, function (ctx) {
|
||||||
|
if (ctx.element.dataset.sdkOrderTest) order.push('A');
|
||||||
|
return ctx;
|
||||||
|
}, { source: '_test-render-order-A' });
|
||||||
|
sw.pipe.render(20, function (ctx) {
|
||||||
|
if (ctx.element.dataset.sdkOrderTest) order.push('B');
|
||||||
|
return ctx;
|
||||||
|
}, { source: '_test-render-order-B' });
|
||||||
|
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.dataset.sdkOrderTest = 'true';
|
||||||
|
sw.pipe._runRender({
|
||||||
|
element: el, message: null,
|
||||||
|
channel: { id: 'fake', type: 'direct' }
|
||||||
|
});
|
||||||
|
T.assert(order.length >= 2, 'both filters should run');
|
||||||
|
var idxA = order.indexOf('A');
|
||||||
|
var idxB = order.indexOf('B');
|
||||||
|
T.assert(idxA < idxB, 'A (p=10) should run before B (p=20)');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// PIPE SCOPING
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('sdk', 'pipe-scope', 'Scoped filter skips non-matching channel', async function () {
|
||||||
|
var ran = false;
|
||||||
|
sw.pipe.pre(200, function (ctx) {
|
||||||
|
ran = true; return ctx;
|
||||||
|
}, { source: '_test-scope-skip', scope: { channelType: ['workflow'] } });
|
||||||
|
|
||||||
|
sw.pipe._runPre({
|
||||||
|
message: 'test', channel: { id: 'x', type: 'direct', title: '' },
|
||||||
|
attachments: [], model: 'test', persona: null,
|
||||||
|
metadata: {}, regenerate: false, regenerateMessageId: null
|
||||||
|
});
|
||||||
|
T.assert(!ran, 'scoped-to-workflow filter should NOT run on direct channel');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'pipe-scope', 'Scoped filter runs on matching channel', async function () {
|
||||||
|
var ran = false;
|
||||||
|
sw.pipe.pre(201, function (ctx) {
|
||||||
|
ran = true; return ctx;
|
||||||
|
}, { source: '_test-scope-match', scope: { channelType: ['dm', 'direct'] } });
|
||||||
|
|
||||||
|
sw.pipe._runPre({
|
||||||
|
message: 'test', channel: { id: 'x', type: 'direct', title: '' },
|
||||||
|
attachments: [], model: 'test', persona: null,
|
||||||
|
metadata: {}, regenerate: false, regenerateMessageId: null
|
||||||
|
});
|
||||||
|
T.assert(ran, 'scoped-to-direct filter should run on direct channel');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'pipe-scope', 'Unscoped filter runs on all channels', async function () {
|
||||||
|
var count = 0;
|
||||||
|
sw.pipe.pre(202, function (ctx) {
|
||||||
|
count++; return ctx;
|
||||||
|
}, { source: '_test-scope-all' });
|
||||||
|
|
||||||
|
sw.pipe._runPre({
|
||||||
|
message: 'a', channel: { id: 'x', type: 'direct', title: '' },
|
||||||
|
attachments: [], model: 'test', persona: null,
|
||||||
|
metadata: {}, regenerate: false, regenerateMessageId: null
|
||||||
|
});
|
||||||
|
sw.pipe._runPre({
|
||||||
|
message: 'b', channel: { id: 'x', type: 'workflow', title: '' },
|
||||||
|
attachments: [], model: 'test', persona: null,
|
||||||
|
metadata: {}, regenerate: false, regenerateMessageId: null
|
||||||
|
});
|
||||||
|
T.assert(count >= 2, 'unscoped filter should run on both channel types');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// EXTENSION COMPAT SHIM
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('sdk', 'compat', 'ctx.renderers.register post → pipe.list() [chat-only]', async function () {
|
||||||
|
// This tests the compat shim in extensions.js.
|
||||||
|
// Extensions only loads on the chat surface (scripts-chat template).
|
||||||
|
if (typeof Extensions === 'undefined') {
|
||||||
|
T.skip('Extensions only loads on chat surface — run SDK tier from /chat to test compat shim');
|
||||||
|
}
|
||||||
|
var shimName = '_sdk-compat-test-' + Date.now();
|
||||||
|
Extensions._registerRenderer('sdk-test', shimName, {
|
||||||
|
type: 'post',
|
||||||
|
priority: 77,
|
||||||
|
render: function (container) { /* noop */ }
|
||||||
|
});
|
||||||
|
var list = sw.pipe.list();
|
||||||
|
var found = list.render.some(function (f) {
|
||||||
|
return f.source === 'sdk-test:' + shimName;
|
||||||
|
});
|
||||||
|
T.assert(found, 'shimmed post-renderer should appear in pipe.list().render');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// INTROSPECTION
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('sdk', 'introspection', 'sw.pipe.list() shape', async function () {
|
||||||
|
var list = sw.pipe.list();
|
||||||
|
T.assertHasKey(list, 'pre', 'pipe.list');
|
||||||
|
T.assertHasKey(list, 'stream', 'pipe.list');
|
||||||
|
T.assertHasKey(list, 'render', 'pipe.list');
|
||||||
|
T.assert(Array.isArray(list.pre), 'pre should be array');
|
||||||
|
T.assert(Array.isArray(list.stream), 'stream should be array');
|
||||||
|
T.assert(Array.isArray(list.render), 'render should be array');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'introspection', 'Stats populated after execution', async function () {
|
||||||
|
var list = sw.pipe.list();
|
||||||
|
// Find any filter with calls > 0 (previous tests should have run some)
|
||||||
|
var anyRun = false;
|
||||||
|
['pre', 'stream', 'render'].forEach(function (stage) {
|
||||||
|
list[stage].forEach(function (f) {
|
||||||
|
if (f.calls > 0) anyRun = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
T.assert(anyRun, 'at least one filter should have calls > 0 from previous tests');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'introspection', 'Stats include avgMs and errors', async function () {
|
||||||
|
var list = sw.pipe.list();
|
||||||
|
var entry = list.pre.find(function (f) { return f.calls > 0; });
|
||||||
|
if (!entry) throw new Error('no filter with calls > 0');
|
||||||
|
T.assert(typeof entry.avgMs === 'number', 'avgMs should be number');
|
||||||
|
T.assert(typeof entry.errors === 'number', 'errors should be number');
|
||||||
|
T.assert(typeof entry.source === 'string', 'source should be string');
|
||||||
|
T.assert(typeof entry.priority === 'number', 'priority should be number');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'introspection', 'Scoped filter shows scope in list', async function () {
|
||||||
|
var list = sw.pipe.list();
|
||||||
|
var scoped = list.pre.find(function (f) { return f.source === '_test-scoped'; });
|
||||||
|
T.assert(scoped, '_test-scoped filter should exist');
|
||||||
|
T.assert(scoped.scope !== null && scoped.scope !== undefined, 'scope should be set');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('sdk', 'introspection', 'Unscoped filter shows null scope', async function () {
|
||||||
|
var list = sw.pipe.list();
|
||||||
|
var unscoped = list.pre.find(function (f) { return f.source === '_test-pre'; });
|
||||||
|
T.assert(unscoped, '_test-pre filter should exist');
|
||||||
|
T.assert(unscoped.scope === null, 'scope should be null for unscoped filter');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -230,6 +230,14 @@
|
|||||||
}, 'Providers');
|
}, 'Providers');
|
||||||
T.el.controls.appendChild(btnProv);
|
T.el.controls.appendChild(btnProv);
|
||||||
|
|
||||||
|
// SDK tier button — tests switchboard-sdk.js contract
|
||||||
|
var btnSdk = $('button', {
|
||||||
|
className: (typeof Switchboard !== 'undefined') ? 'btn-secondary' : 'btn-ghost',
|
||||||
|
style: { opacity: (typeof Switchboard !== 'undefined') ? '1' : '0.5' },
|
||||||
|
onClick: function () { T.runSuite('sdk'); }
|
||||||
|
}, 'SDK');
|
||||||
|
T.el.controls.appendChild(btnSdk);
|
||||||
|
|
||||||
T.el.controls.appendChild(btnAll);
|
T.el.controls.appendChild(btnAll);
|
||||||
T.el.controls.appendChild(btnClear);
|
T.el.controls.appendChild(btnClear);
|
||||||
|
|
||||||
@@ -342,6 +350,7 @@
|
|||||||
var now = new Date().toISOString();
|
var now = new Date().toISOString();
|
||||||
var pass = T.results.filter(function (r) { return r.status === 'pass'; }).length;
|
var pass = T.results.filter(function (r) { return r.status === 'pass'; }).length;
|
||||||
var fail = T.results.filter(function (r) { return r.status === 'fail'; }).length;
|
var fail = T.results.filter(function (r) { return r.status === 'fail'; }).length;
|
||||||
|
var skip = T.results.filter(function (r) { return r.status === 'skip'; }).length;
|
||||||
var total = T.results.length;
|
var total = T.results.length;
|
||||||
var totalMs = T.results.reduce(function (s, r) { return s + r.duration; }, 0);
|
var totalMs = T.results.reduce(function (s, r) { return s + r.duration; }, 0);
|
||||||
|
|
||||||
@@ -353,7 +362,7 @@
|
|||||||
lines.push('URL: ' + location.href);
|
lines.push('URL: ' + location.href);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
lines.push('--- Summary ---');
|
lines.push('--- Summary ---');
|
||||||
lines.push('Total: ' + total + ' Pass: ' + pass + ' Fail: ' + fail + ' Rate: ' + (total > 0 ? Math.round((pass / total) * 100) : 0) + '% Duration: ' + totalMs + 'ms');
|
lines.push('Total: ' + total + ' Pass: ' + pass + ' Fail: ' + fail + (skip > 0 ? ' Skip: ' + skip : '') + ' Rate: ' + (total > 0 ? Math.round((pass / (total - skip)) * 100) : 0) + '% Duration: ' + totalMs + 'ms');
|
||||||
|
|
||||||
// Critical failures callout
|
// Critical failures callout
|
||||||
var criticals = T.results.filter(function (r) { return r.status === 'fail' && r.detail && r.detail.indexOf('CRITICAL') !== -1; });
|
var criticals = T.results.filter(function (r) { return r.status === 'fail' && r.detail && r.detail.indexOf('CRITICAL') !== -1; });
|
||||||
@@ -391,17 +400,18 @@
|
|||||||
var domains = {};
|
var domains = {};
|
||||||
T.results.forEach(function (r) {
|
T.results.forEach(function (r) {
|
||||||
var k = r.domain;
|
var k = r.domain;
|
||||||
if (!domains[k]) domains[k] = { pass: 0, fail: 0, total: 0, ms: 0 };
|
if (!domains[k]) domains[k] = { pass: 0, fail: 0, skip: 0, total: 0, ms: 0 };
|
||||||
domains[k].total++;
|
domains[k].total++;
|
||||||
domains[k].ms += r.duration;
|
domains[k].ms += r.duration;
|
||||||
if (r.status === 'pass') domains[k].pass++;
|
if (r.status === 'pass') domains[k].pass++;
|
||||||
|
else if (r.status === 'skip') domains[k].skip++;
|
||||||
else domains[k].fail++;
|
else domains[k].fail++;
|
||||||
});
|
});
|
||||||
|
|
||||||
lines.push('--- By Domain ---');
|
lines.push('--- By Domain ---');
|
||||||
Object.keys(domains).forEach(function (d) {
|
Object.keys(domains).forEach(function (d) {
|
||||||
var s = domains[d];
|
var s = domains[d];
|
||||||
var flag = s.fail > 0 ? 'FAIL' : 'OK';
|
var flag = s.fail > 0 ? 'FAIL' : (s.skip > 0 && s.pass === 0 ? 'SKIP' : 'OK');
|
||||||
lines.push(' ' + pad(d, 16) + pad(flag, 6) + pad(s.pass + '/' + s.total, 8) + s.ms + 'ms');
|
lines.push(' ' + pad(d, 16) + pad(flag, 6) + pad(s.pass + '/' + s.total, 8) + s.ms + 'ms');
|
||||||
});
|
});
|
||||||
lines.push('');
|
lines.push('');
|
||||||
@@ -417,16 +427,30 @@
|
|||||||
lines.push('');
|
lines.push('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skipped (if any)
|
||||||
|
var skipped = T.results.filter(function (r) { return r.status === 'skip'; });
|
||||||
|
if (skipped.length > 0) {
|
||||||
|
lines.push('--- Skipped (' + skipped.length + ') ---');
|
||||||
|
skipped.forEach(function (r, i) {
|
||||||
|
lines.push(' ' + (i + 1) + '. [' + r.tier + '/' + r.domain + '] ' + r.name);
|
||||||
|
lines.push(' ' + r.detail);
|
||||||
|
});
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
// Full detail table
|
// Full detail table
|
||||||
lines.push('--- Full Results ---');
|
lines.push('--- Full Results ---');
|
||||||
lines.push(pad('STATUS', 8) + pad('TIER', 8) + pad('DOMAIN', 16) + pad('MS', 6) + 'TEST');
|
lines.push(pad('STATUS', 8) + pad('TIER', 8) + pad('DOMAIN', 16) + pad('MS', 6) + 'TEST');
|
||||||
lines.push(repeat('-', 90));
|
lines.push(repeat('-', 90));
|
||||||
T.results.forEach(function (r) {
|
T.results.forEach(function (r) {
|
||||||
var statusStr = r.status === 'pass' ? 'PASS' : 'FAIL';
|
var statusStr = r.status === 'pass' ? 'PASS' : (r.status === 'skip' ? 'SKIP' : 'FAIL');
|
||||||
var line = pad(statusStr, 8) + pad(r.tier, 8) + pad(r.domain, 16) + pad(String(r.duration), 6) + r.name;
|
var line = pad(statusStr, 8) + pad(r.tier, 8) + pad(r.domain, 16) + pad(String(r.duration), 6) + r.name;
|
||||||
if (r.status === 'fail' && r.detail) {
|
if (r.status === 'fail' && r.detail) {
|
||||||
line += '\n' + repeat(' ', 38) + '↳ ' + r.detail;
|
line += '\n' + repeat(' ', 38) + '↳ ' + r.detail;
|
||||||
}
|
}
|
||||||
|
if (r.status === 'skip' && r.detail) {
|
||||||
|
line += '\n' + repeat(' ', 38) + '↳ SKIP: ' + r.detail;
|
||||||
|
}
|
||||||
lines.push(line);
|
lines.push(line);
|
||||||
});
|
});
|
||||||
lines.push('');
|
lines.push('');
|
||||||
@@ -521,6 +545,7 @@
|
|||||||
if (which === 'authz' || (which === 'all' && T.fixtures.ready)) await T.runAuthz();
|
if (which === 'authz' || (which === 'all' && T.fixtures.ready)) await T.runAuthz();
|
||||||
if (which === 'security' || (which === 'all' && T.fixtures.ready)) await T.runSecurity();
|
if (which === 'security' || (which === 'all' && T.fixtures.ready)) await T.runSecurity();
|
||||||
if (which === 'provider' || (which === 'all' && T.providerSetup.configured)) await T.runProviders();
|
if (which === 'provider' || (which === 'all' && T.providerSetup.configured)) await T.runProviders();
|
||||||
|
if (which === 'sdk' || which === 'all') { if (typeof T.runSdk === 'function') await T.runSdk(); }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
T.results.push({ tier: '?', domain: 'runner', name: 'FATAL', status: 'fail', duration: 0, detail: String(e) });
|
T.results.push({ tier: '?', domain: 'runner', name: 'FATAL', status: 'fail', duration: 0, detail: String(e) });
|
||||||
}
|
}
|
||||||
@@ -546,8 +571,10 @@
|
|||||||
if (!T.el.progress) return;
|
if (!T.el.progress) return;
|
||||||
var pass = T.results.filter(function (r) { return r.status === 'pass'; }).length;
|
var pass = T.results.filter(function (r) { return r.status === 'pass'; }).length;
|
||||||
var fail = T.results.filter(function (r) { return r.status === 'fail'; }).length;
|
var fail = T.results.filter(function (r) { return r.status === 'fail'; }).length;
|
||||||
|
var skip = T.results.filter(function (r) { return r.status === 'skip'; }).length;
|
||||||
var total = T.results.length;
|
var total = T.results.length;
|
||||||
var pct = total > 0 ? Math.round((pass / total) * 100) : 0;
|
var countable = total - skip;
|
||||||
|
var pct = countable > 0 ? Math.round((pass / countable) * 100) : 0;
|
||||||
|
|
||||||
T.el.progress.innerHTML = '';
|
T.el.progress.innerHTML = '';
|
||||||
if (total === 0) return;
|
if (total === 0) return;
|
||||||
@@ -571,7 +598,7 @@
|
|||||||
T.el.progress.appendChild(barOuter);
|
T.el.progress.appendChild(barOuter);
|
||||||
|
|
||||||
var label = $('div', { style: { fontSize: '12px', color: 'var(--text-2)' } },
|
var label = $('div', { style: { fontSize: '12px', color: 'var(--text-2)' } },
|
||||||
(T.running ? 'Running... ' : '') + total + ' tests · ' + pass + ' pass · ' + fail + ' fail · ' + pct + '%');
|
(T.running ? 'Running... ' : '') + total + ' tests · ' + pass + ' pass · ' + fail + ' fail' + (skip > 0 ? ' · ' + skip + ' skip' : '') + ' · ' + pct + '%');
|
||||||
T.el.progress.appendChild(label);
|
T.el.progress.appendChild(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,10 +611,11 @@
|
|||||||
var domains = {};
|
var domains = {};
|
||||||
T.results.forEach(function (r) {
|
T.results.forEach(function (r) {
|
||||||
var k = r.domain;
|
var k = r.domain;
|
||||||
if (!domains[k]) domains[k] = { pass: 0, fail: 0, total: 0, ms: 0 };
|
if (!domains[k]) domains[k] = { pass: 0, fail: 0, skip: 0, total: 0, ms: 0 };
|
||||||
domains[k].total++;
|
domains[k].total++;
|
||||||
domains[k].ms += r.duration;
|
domains[k].ms += r.duration;
|
||||||
if (r.status === 'pass') domains[k].pass++;
|
if (r.status === 'pass') domains[k].pass++;
|
||||||
|
else if (r.status === 'skip') domains[k].skip++;
|
||||||
else domains[k].fail++;
|
else domains[k].fail++;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -600,17 +628,19 @@
|
|||||||
|
|
||||||
Object.keys(domains).forEach(function (d) {
|
Object.keys(domains).forEach(function (d) {
|
||||||
var s = domains[d];
|
var s = domains[d];
|
||||||
var allPass = s.fail === 0;
|
var borderColor = s.fail > 0 ? 'var(--danger)' : (s.skip > 0 ? 'var(--warning)' : 'var(--success)');
|
||||||
|
var label = s.pass + '/' + s.total + ' pass';
|
||||||
|
if (s.skip > 0) label += ' · ' + s.skip + ' skip';
|
||||||
|
label += ' · ' + s.ms + 'ms';
|
||||||
var card = $('div', {
|
var card = $('div', {
|
||||||
style: {
|
style: {
|
||||||
background: 'var(--bg-surface)', border: '1px solid var(--border)',
|
background: 'var(--bg-surface)', border: '1px solid var(--border)',
|
||||||
borderRadius: 'var(--radius)', padding: '10px 14px',
|
borderRadius: 'var(--radius)', padding: '10px 14px',
|
||||||
borderLeft: '3px solid ' + (allPass ? 'var(--success)' : 'var(--danger)')
|
borderLeft: '3px solid ' + borderColor
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
$('div', { style: { fontSize: '13px', fontWeight: '600', color: 'var(--text)', marginBottom: '4px' } }, d),
|
$('div', { style: { fontSize: '13px', fontWeight: '600', color: 'var(--text)', marginBottom: '4px' } }, d),
|
||||||
$('div', { style: { fontSize: '12px', color: 'var(--text-2)' } },
|
$('div', { style: { fontSize: '12px', color: 'var(--text-2)' } }, label)
|
||||||
s.pass + '/' + s.total + ' pass · ' + s.ms + 'ms')
|
|
||||||
]);
|
]);
|
||||||
grid.appendChild(card);
|
grid.appendChild(card);
|
||||||
});
|
});
|
||||||
@@ -649,10 +679,14 @@
|
|||||||
var tbody = $('tbody');
|
var tbody = $('tbody');
|
||||||
T.results.forEach(function (r) {
|
T.results.forEach(function (r) {
|
||||||
var isPass = r.status === 'pass';
|
var isPass = r.status === 'pass';
|
||||||
|
var isSkip = r.status === 'skip';
|
||||||
|
var rowBg = isPass ? 'transparent' : (isSkip ? 'rgba(234,179,8,0.04)' : 'rgba(239,68,68,0.04)');
|
||||||
|
var dotColor = isPass ? 'var(--success)' : (isSkip ? 'var(--warning)' : 'var(--danger)');
|
||||||
|
var detailColor = isPass ? 'var(--text-3)' : (isSkip ? 'var(--warning)' : 'var(--danger)');
|
||||||
var tr = $('tr', {
|
var tr = $('tr', {
|
||||||
style: {
|
style: {
|
||||||
borderBottom: '1px solid var(--border)',
|
borderBottom: '1px solid var(--border)',
|
||||||
background: isPass ? 'transparent' : 'rgba(239,68,68,0.04)'
|
background: rowBg
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -661,7 +695,7 @@
|
|||||||
$('span', {
|
$('span', {
|
||||||
style: {
|
style: {
|
||||||
display: 'inline-block', width: '8px', height: '8px',
|
display: 'inline-block', width: '8px', height: '8px',
|
||||||
borderRadius: '50%', background: isPass ? 'var(--success)' : 'var(--danger)'
|
borderRadius: '50%', background: dotColor
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]));
|
]));
|
||||||
@@ -671,21 +705,22 @@
|
|||||||
tr.appendChild($('td', { style: { padding: '6px 10px', color: 'var(--text)' } }, r.name));
|
tr.appendChild($('td', { style: { padding: '6px 10px', color: 'var(--text)' } }, r.name));
|
||||||
tr.appendChild($('td', { style: { padding: '6px 10px', color: 'var(--text-3)', textAlign: 'right' } }, String(r.duration)));
|
tr.appendChild($('td', { style: { padding: '6px 10px', color: 'var(--text-3)', textAlign: 'right' } }, String(r.duration)));
|
||||||
|
|
||||||
|
var detailText = isSkip ? ('SKIP: ' + (r.detail || '')) : (r.detail || '');
|
||||||
var detailTd = $('td', {
|
var detailTd = $('td', {
|
||||||
style: {
|
style: {
|
||||||
padding: '6px 10px', color: isPass ? 'var(--text-3)' : 'var(--danger)',
|
padding: '6px 10px', color: detailColor,
|
||||||
maxWidth: '320px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
maxWidth: '320px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
cursor: r.detail ? 'pointer' : 'default'
|
cursor: detailText ? 'pointer' : 'default'
|
||||||
},
|
},
|
||||||
title: r.detail || ''
|
title: detailText || ''
|
||||||
}, r.detail ? r.detail.substring(0, 120) : '✓');
|
}, detailText ? detailText.substring(0, 120) : '✓');
|
||||||
|
|
||||||
if (r.detail && r.detail.length > 120) {
|
if (detailText && detailText.length > 120) {
|
||||||
detailTd.addEventListener('click', function () {
|
detailTd.addEventListener('click', function () {
|
||||||
if (detailTd.style.whiteSpace === 'nowrap') {
|
if (detailTd.style.whiteSpace === 'nowrap') {
|
||||||
detailTd.style.whiteSpace = 'pre-wrap';
|
detailTd.style.whiteSpace = 'pre-wrap';
|
||||||
detailTd.style.wordBreak = 'break-all';
|
detailTd.style.wordBreak = 'break-all';
|
||||||
detailTd.textContent = r.detail;
|
detailTd.textContent = detailText;
|
||||||
} else {
|
} else {
|
||||||
detailTd.style.whiteSpace = 'nowrap';
|
detailTd.style.whiteSpace = 'nowrap';
|
||||||
detailTd.textContent = r.detail.substring(0, 120);
|
detailTd.textContent = r.detail.substring(0, 120);
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"route": "/s/icd-test-runner",
|
"route": "/s/icd-test-runner",
|
||||||
"auth": "authenticated",
|
"auth": "authenticated",
|
||||||
"layout": "single",
|
"layout": "single",
|
||||||
"version": "0.28.4.0",
|
"version": "0.28.5.0",
|
||||||
"description": "Integration test runner — validates ICD endpoint contracts across smoke, CRUD, AuthZ, Security, and provider tiers."
|
"description": "Integration test runner — validates ICD endpoint contracts across smoke, CRUD, AuthZ, Security, Provider, and SDK tiers."
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user