Changeset 0.36.0 (#212)

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

View File

@@ -1,5 +1,57 @@
# Changelog
## [0.36.0] — 2026-03-20
### Summary
Full OpenAPI 3.0.3 specification covering all API domains. Expands the
hand-curated spec from v0.33.0 (12 endpoints) to 296 paths / 216
operations across all ICD-documented domains. Documents Bearer JWT,
mTLS, and OIDC auth modes. Adds reusable schemas, error responses,
and pagination parameters.
### New
- **OpenAPI spec expanded to 296 paths** — up from 12 endpoints.
Covers: Auth (builtin + OIDC), Profile & Settings, Channels (CRUD,
participants, models, KB bindings, presence, folders, typing,
summarize, title generation), Messages (tree walk, edit, regenerate,
siblings, cursor), Completions (streaming + non-streaming), Files
(upload, download, metadata, message files), Personas (3-scope CRUD,
avatars, KB bindings, tool grants, persona groups), Models (enabled
list, preferences, bulk), Providers (BYOK CRUD, model sync), Notes
(CRUD, search, graph, backlinks, bulk delete), Knowledge Bases
(CRUD, documents, search, rebuild, discoverable), Memory (CRUD,
approve/reject, count, compact), Projects (CRUD, channels, KBs,
notes, files, reorder), Workspaces (CRUD, file ops, archive, git),
Git Operations (clone, pull, push, status, diff, commit, log,
branches, checkout), Git Credentials, Tasks (CRUD, runs, kill),
Workflows (CRUD, stages, publish, versions, instances, advance,
reject), Workflow Assignments (list, claim, complete, comment),
Workflow Forms (template, submit, visitor entry), Notifications
(CRUD, preferences, unread count), Data Portability (export, import,
ChatGPT import, GDPR delete), Webhook triggers, Health probes,
Metrics, WebSocket tickets.
- **29 reusable component schemas** — AuthResponse, AuthUser,
UserProfile, Channel, Message, Participant, FileObject, ToolDef,
CompletionRequest/Response, TokenUsage, PersonaInput,
ProviderConfigInput, ProviderHealth, ErrorResponse, MessageResponse.
- **Standardized error responses** — 400, 401, 403, 404, 409, 429
with $ref reuse across all paths.
- **Security scheme documentation** — bearerAuth (JWT, 15m TTL) and
mtlsAuth (mutual TLS via proxy headers).
- **Reusable parameters** — ResourceID, Page, PerPage for consistent
pagination across all list endpoints.
- **25 domain tags** pre-declared for Swagger UI navigation.
- **AuthResponse schema corrected** — `access_token` (was incorrectly
`token`), added `token_type`, `expires_in`, `handle`, `auth_source`.
- **Login endpoint corrected** — field is `login` (accepts username
or email), not `username`.
### Changed
- `server/static/openapi.yaml` — complete rewrite (643 → 11845 lines)
## [0.35.1] — 2026-03-20
### Summary

View File

@@ -1 +1 @@
0.35.1
0.36.0

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -256,7 +256,7 @@
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: '/api/docs/openapi.yaml',
url: window.location.pathname.replace(/\/$/, '') + '/openapi.yaml',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],

View File

@@ -1,29 +1,3 @@
package main
import (
"os"
"strings"
)
// Version is set at build time via:
//
// go build -ldflags "-X main.Version=$(cat VERSION)"
//
// Falls back to reading /VERSION or ./VERSION for container/local runs
// without ldflags. Final fallback is "dev".
var Version = "dev"
func init() {
if Version != "dev" {
return
}
// Try well-known paths: container (/VERSION), repo root (../VERSION, ./VERSION)
for _, path := range []string{"/VERSION", "VERSION", "../VERSION"} {
if data, err := os.ReadFile(path); err == nil {
if v := strings.TrimSpace(string(data)); v != "" {
Version = v
return
}
}
}
}
const Version = "0.36.0"