From b7746c30041e1d984b1014bceafd734fd7b67d33 Mon Sep 17 00:00:00 2001 From: gobha Date: Mon, 23 Mar 2026 16:47:48 +0000 Subject: [PATCH] Changeset 0.37.14 (#226) Co-authored-by: gobha Co-committed-by: gobha --- .gitea/workflows/ci.yaml | 14 +- CHANGELOG.md | 183 +++++ FE-REWRITE-REVIEW-0.37.14.md | 237 ++++++ ICD-DRIFT-AUDIT.md | 95 +++ TURNOVER-0.37.14-S8.md | 129 ++++ VERSION | 2 +- docker-compose.yml | 2 + docs/ICD/admin.md | 7 +- docs/ICD/channels.md | 28 +- docs/ICD/profile.md | 59 ++ docs/ROADMAP.md | 614 ++------------- docs/Workflow/DESIGN-0.37.15.md | 715 ++++++++++++++++++ docs/Workflow/ROADMAP-0.38.md | 584 ++++++++++++++ nginx.conf | 7 +- packages/dashboard/js/main.js | 4 +- packages/icd-test-runner/js/crud/channels.js | 60 ++ packages/icd-test-runner/js/framework.js | 67 +- packages/icd-test-runner/js/main.js | 30 +- packages/icd-test-runner/js/tier-sdk.js | 68 +- packages/icd-test-runner/js/tier-smoke.js | 1 - packages/icd-test-runner/js/ui.js | 34 +- server/database/migrations/001_core.sql | 3 +- server/database/migrations/005_channels.sql | 4 + .../database/migrations/sqlite/001_core.sql | 3 +- .../migrations/sqlite/005_channels.sql | 1 + server/events/types.go | 1 + server/events/ws.go | 2 +- server/handlers/admin.go | 36 +- server/handlers/apiconfigs.go | 4 +- server/handlers/auth.go | 3 + server/handlers/channel_models.go | 8 +- server/handlers/channels.go | 165 +++- server/handlers/completion.go | 53 +- server/handlers/completion_chain.go | 40 +- server/handlers/folders.go | 35 +- server/handlers/integration_test.go | 60 +- server/handlers/live_provider_test.go | 8 +- server/handlers/messages.go | 166 +++- server/handlers/notes.go | 2 +- server/handlers/package_registry.go | 6 +- server/handlers/packages.go | 2 +- server/handlers/participants.go | 8 +- server/handlers/presence.go | 2 +- server/handlers/profile_bootstrap.go | 117 +++ server/handlers/roles.go | 2 +- server/handlers/team_providers.go | 2 +- server/handlers/teams.go | 2 +- server/main.go | 11 + server/models/models.go | 5 +- server/pages/loaders.go | 2 +- server/pages/pages.go | 63 +- server/pages/templates/base.html | 83 +- server/pages/templates/surfaces/chat.html | 1 + .../pages/templates/surfaces/extension.html | 3 - server/pages/templates/surfaces/notes.html | 1 + server/pages/templates/workflow.html | 30 +- server/retention/scanner.go | 124 +++ server/static/openapi.yaml | 24 +- server/store/interfaces.go | 8 +- server/store/postgres/channel.go | 36 +- server/store/postgres/folder.go | 21 +- server/store/postgres/message.go | 2 +- server/store/sqlite/channel.go | 36 +- server/store/sqlite/folder.go | 21 +- server/store/sqlite/message.go | 2 +- server/version.go | 24 +- src/css/primitives.css | 122 +++ src/css/sw-chat-pane.css | 137 +++- src/css/sw-chat-surface.css | 439 ++++++++++- src/css/sw-notes-surface.css | 13 +- src/css/sw-primitives.css | 5 + src/css/sw-shell.css | 186 +++++ src/css/user-menu.css | 15 + src/css/variables.css | 6 + src/editor/chat-input.mjs | 61 +- src/editor/index.mjs | 5 + src/editor/mention.mjs | 156 ++++ src/favicon-light.svg | 51 ++ src/js/__tests__/api-contracts.test.js | 86 +-- src/js/__tests__/auth-resilience.test.js | 79 ++ src/js/__tests__/channel-contracts.test.js | 149 ++++ src/js/__tests__/policy-gating.test.js | 47 +- src/js/debug.js | 111 +-- src/js/events.js | 361 --------- src/js/repl.js | 46 +- src/js/sb.js | 133 ---- .../sw/components/chat-pane/chat-history.js | 2 +- src/js/sw/components/chat-pane/index.js | 14 +- .../components/chat-pane/markdown-toolbar.js | 110 --- src/js/sw/components/chat-pane/markdown.js | 45 +- .../sw/components/chat-pane/message-bubble.js | 24 +- .../sw/components/chat-pane/message-input.js | 196 ++++- .../sw/components/chat-pane/message-list.js | 19 +- .../sw/components/chat-pane/model-selector.js | 19 +- .../components/chat-pane/typing-indicator.js | 38 + src/js/sw/components/chat-pane/use-chat.js | 222 +++++- src/js/sw/components/chat-pane/use-stream.js | 26 +- src/js/sw/components/notes-pane/markdown.js | 2 +- .../sw/components/notes-pane/note-editor.js | 2 +- .../sw/components/notes-pane/note-reader.js | 2 +- .../components/notes-pane/quick-switcher.js | 2 +- .../sw/components/notes-pane/save-to-note.js | 2 +- src/js/sw/components/notes-pane/use-notes.js | 18 +- src/js/sw/primitives/menu.js | 16 +- src/js/sw/primitives/user-picker.js | 130 ++++ src/js/sw/sdk/api-domains.js | 41 +- src/js/sw/sdk/auth.js | 23 +- src/js/sw/sdk/index.js | 4 +- src/js/sw/sdk/pipe.js | 6 + src/js/sw/sdk/rest-client.js | 6 +- src/js/sw/shell/notification-bell.js | 152 ++++ src/js/sw/shell/user-menu.js | 65 +- src/js/sw/surfaces/admin/capabilities.js | 4 +- src/js/sw/surfaces/admin/channels.js | 41 +- src/js/sw/surfaces/admin/groups.js | 10 +- src/js/sw/surfaces/admin/health.js | 2 +- src/js/sw/surfaces/admin/index.js | 48 +- src/js/sw/surfaces/admin/knowledge.js | 8 +- src/js/sw/surfaces/admin/memory.js | 2 +- src/js/sw/surfaces/admin/models.js | 2 +- src/js/sw/surfaces/admin/packages.js | 269 ++++++- src/js/sw/surfaces/admin/personas.js | 6 +- src/js/sw/surfaces/admin/providers.js | 6 +- src/js/sw/surfaces/admin/roles.js | 12 +- src/js/sw/surfaces/admin/routing.js | 2 +- src/js/sw/surfaces/admin/settings.js | 92 ++- src/js/sw/surfaces/admin/tasks.js | 4 +- src/js/sw/surfaces/admin/teams.js | 8 +- src/js/sw/surfaces/admin/usage.js | 2 +- src/js/sw/surfaces/admin/users.js | 6 +- src/js/sw/surfaces/admin/workflows.js | 2 +- .../sw/surfaces/chat/channel-members-panel.js | 104 +++ .../surfaces/chat/channel-settings-panel.js | 108 +++ src/js/sw/surfaces/chat/chat-workspace.js | 169 ++++- src/js/sw/surfaces/chat/index.js | 52 +- src/js/sw/surfaces/chat/sidebar-channels.js | 65 -- src/js/sw/surfaces/chat/sidebar-chats.js | 398 +++++++--- src/js/sw/surfaces/chat/sidebar.js | 111 ++- src/js/sw/surfaces/chat/tools-popup.js | 11 +- src/js/sw/surfaces/chat/use-sidebar.js | 98 ++- src/js/sw/surfaces/chat/use-workspace.js | 27 +- src/js/sw/surfaces/notes/notes-workspace.js | 5 +- src/js/sw/surfaces/settings/appearance.js | 19 +- src/js/sw/surfaces/settings/general.js | 2 +- src/js/sw/surfaces/settings/gitkeys.js | 2 +- src/js/sw/surfaces/settings/knowledge.js | 8 +- src/js/sw/surfaces/settings/memory.js | 2 +- src/js/sw/surfaces/settings/models.js | 4 +- src/js/sw/surfaces/settings/personas.js | 2 +- src/js/sw/surfaces/settings/providers.js | 2 +- src/js/sw/surfaces/settings/roles.js | 4 +- src/js/sw/surfaces/settings/tasks.js | 2 +- src/js/sw/surfaces/settings/teams.js | 2 +- src/js/sw/surfaces/settings/workflows.js | 2 +- src/js/sw/surfaces/team-admin/groups.js | 2 +- src/js/sw/surfaces/team-admin/knowledge.js | 2 +- src/js/sw/surfaces/team-admin/members.js | 6 +- src/js/sw/surfaces/team-admin/personas.js | 2 +- src/js/sw/surfaces/team-admin/providers.js | 4 +- src/js/sw/surfaces/team-admin/tasks.js | 2 +- src/js/sw/surfaces/team-admin/workflows.js | 4 +- src/js/switchboard-sdk.js | 697 ----------------- src/js/workflow-surfaces.js | 481 ------------ src/sw.js | 8 +- 164 files changed, 6972 insertions(+), 3527 deletions(-) create mode 100644 FE-REWRITE-REVIEW-0.37.14.md create mode 100644 ICD-DRIFT-AUDIT.md create mode 100644 TURNOVER-0.37.14-S8.md create mode 100644 docs/Workflow/DESIGN-0.37.15.md create mode 100644 docs/Workflow/ROADMAP-0.38.md create mode 100644 server/handlers/profile_bootstrap.go create mode 100644 server/retention/scanner.go create mode 100644 src/editor/mention.mjs create mode 100644 src/favicon-light.svg create mode 100644 src/js/__tests__/channel-contracts.test.js delete mode 100644 src/js/events.js delete mode 100644 src/js/sb.js delete mode 100644 src/js/sw/components/chat-pane/markdown-toolbar.js create mode 100644 src/js/sw/components/chat-pane/typing-indicator.js create mode 100644 src/js/sw/primitives/user-picker.js create mode 100644 src/js/sw/shell/notification-bell.js create mode 100644 src/js/sw/surfaces/chat/channel-members-panel.js create mode 100644 src/js/sw/surfaces/chat/channel-settings-panel.js delete mode 100644 src/js/sw/surfaces/chat/sidebar-channels.js delete mode 100644 src/js/switchboard-sdk.js delete mode 100644 src/js/workflow-surfaces.js diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index b44a952..c96f81d 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -202,10 +202,16 @@ jobs: - name: Syntax check (all JS) run: | echo "Checking JS syntax..." - for f in src/js/*.js; do - node --check "$f" || exit 1 - echo " ✓ $f" - done + err=0 + while IFS= read -r f; do + # Feed file as module to node --check via stdin + node --input-type=module --check < "$f" 2>/dev/null || { + echo " ✗ $f" + err=1 + } + done < <(find src/js -name '*.js' -not -path '*/vendor/*' -not -path '*node_modules*') + [ "$err" -eq 0 ] && echo " ✓ All JS files passed syntax check" + exit $err - name: Run frontend tests run: node --test src/js/__tests__/*.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f73d714..9b86abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,188 @@ # Changelog +## [0.37.14.23] — 2026-03-23 + +### Summary + +Extension surface user menu fix + roadmap workflow integration. The ICD Runner +(and all extension surfaces) now mount the Preact UserMenu via `sw.userMenu()` +instead of relying on the dead Go template hydration. + +### Fixed + +- **Extension surface "? User" label:** extension template included static + `{{template "user-menu"}}` which rendered placeholder HTML ("? User") since + the legacy hydration script was removed in v0.37.13. Removed the dead + template inclusion. (`server/pages/templates/surfaces/extension.html`) +- **Extension surface user menu click does nothing:** ICD Runner never called + `sw.userMenu()` to mount the Preact UserMenu component. Added topbar with + `sw.userMenu(topbar, { flyout: 'down' })` in `renderShell`. Also uses + `sw.auth.user` instead of dead `window.__USER__` for header display. + (`packages/icd-test-runner/js/ui.js`, `packages/icd-test-runner/js/main.js`) + +### Changed + +- **Roadmap:** added v0.37.15 reference to `DESIGN-0.37.15.md` workflow design + doc, added v0.38.x Workflow Product Maturity section referencing + `ROADMAP-0.38.md`. Trimmed completed version descriptions. + +--- + +## [0.37.14.22] — 2026-03-23 + +### Summary + +Thinking tags persistence + deleteMessage endpoint. Two features that close +out the v0.37.14 pane audit. Also fixes an S9 latent bug (messages paginated +response not unwrapped) and a SQLite NULL scan crash in GetByID. + +### Added + +- **deleteMessage server endpoint** — `DELETE /channels/:id/messages/:msgId` + with soft-delete, channel access check, and `message.deleted` WS broadcast + to other participants. (`server/handlers/messages.go`, `server/main.go`) +- **`message.deleted` event type** — registered as `DirToClient` for + cross-tab message removal. (`server/events/types.go`) +- **`deleteMessage` SDK method** — `rc.del(...)` in channels namespace. + (`src/js/sw/sdk/api-domains.js`) +- **`message.deleted` WS handler** — filters by active channel, removes + message from state. (`src/js/sw/components/chat-pane/use-chat.js`) +- **`onDelete` wired in ChatPane** — delete button now visible on all + messages. (`src/js/sw/components/chat-pane/index.js`) +- **`extractThinking` / `hydrateThinking` helpers** — parse `` blocks + from message content on the load path (switchChannel, loadMore, WS + message.created). (`src/js/sw/components/chat-pane/use-chat.js`) + +### Fixed + +- **Thinking tags lost on refresh:** `` content rendered correctly + during streaming (via SSE `reasoning_content`) but spilled as raw text + after page reload. Now extracted on the load path and rendered in the + collapsible `
` block. Frontend-only fix, no DB migration. +- **S9 messages unwrap bug:** messages endpoint returns paginated response + with 5 sibling keys, so `_unwrap` preserves the full object. `resp || []` + produced an object, not an array. Changed to `resp.data || resp || []`. +- **SQLite NULL scan crash:** `GetByID` fails on user messages where `model` + and `tokens_used` are NULL (struct uses `string`/`int`, not pointers). + Delete handler now uses a direct `EXISTS` query instead. + +--- + +## [0.37.14.21] — 2026-03-23 + +### Summary + +Double-unwrap cleanup + API envelope normalization. Eliminated 93 defensive +`resp.data || resp || []` patterns across the frontend by fixing `_unwrap` to +preserve sibling fields and normalizing all Go handlers to use `{data: [...]}` +standard envelopes. Three latent bugs fixed (roles, provider types, audit). + +### Changed + +- `src/js/sw/sdk/rest-client.js` — `_unwrap` now only strips `{data: [...]}` when + `data` is the sole key (`Object.keys(json).length === 1`). Responses with siblings + (e.g. `{data: [...], total: N}`) pass through intact. +- **15 Go handlers** normalized from named keys (`users`, `configs`, `models`, + `channels`, `participants`, `tools`, `folders`, `messages`, `values`, `packages`) + to standard `{data: [...]}` envelope: + - `server/handlers/admin.go` — ListUsers, ListGlobalConfigs, ListModelConfigs, + ListArchivedChannels + - `server/handlers/apiconfigs.go` — ListConfigs, ListModels + - `server/handlers/channel_models.go` — List, Add, Update, Delete + - `server/handlers/participants.go` — List, Add, Update, Remove + - `server/handlers/presence.go` — SearchUsers + - `server/handlers/completion.go` — ListTools + - `server/handlers/folders.go` — List + - `server/handlers/notes.go` — ListFolders + - `server/handlers/messages.go` — GetActivePath, SetCursor + - `server/handlers/teams.go` — ListAvailableModels + - `server/handlers/team_providers.go` — ListTeamProviderModels + - `server/handlers/packages.go` — UpdatePackageSettings + - `server/handlers/package_registry.go` — BrowseRegistry + - `server/handlers/roles.go` — ListRoles (bare map → wrapped in `{data: map}`) +- **~45 frontend files** — simplified all double-unwrap patterns: + - Sole-key endpoints (unwrapped): `resp || []` + - Sibling endpoints (not unwrapped): `resp.data || []`, `resp.total || 0` + - Roles map: `r.data || {}` + `Object.entries()` for iteration +- Go integration + live provider tests updated for new envelope keys +- Frontend contract + policy-gating tests updated + +### Fixed + +- **Admin → Roles always empty:** `ListRoles` returned a raw map; `r.roles` + was undefined on it. Now wrapped in `{data: map}`, frontend iterates via + `Object.entries(roles)`. +- **Admin → Provider Types dropdown always empty:** `GetProviderTypes` returned + `{data: types}`, `_unwrap` stripped it to bare array, `t.types` was undefined. + Now correctly uses `t || []`. +- **Admin → Audit entries always empty:** `_unwrap` stripped `{data, total}` to + just the array, losing `total`. Now preserved because of sibling-aware `_unwrap`. + +### Design Notes + +- **Envelope contract:** All list endpoints now return `{data: [...]}` or + `{data: [...], ...metadata}`. No more `{users: [...]}`, `{models: [...]}`, etc. +- **`_unwrap` contract:** sole-key `{data: [...]}` → bare array; + sibling keys present → full object (frontend accesses `.data` and siblings). +- **Editor package skipped:** `packages/editor/js/main.js` uses `API._get` which + bypasses `_unwrap`; defensive patterns intentionally preserved. + +--- + +## [0.37.14] — 2026-03-22 + +### Summary + +Scorched Earth IV + Pane Audit — fourth dead-code purge. 4 files deleted (~−1,672 lines). +The old global layer (`sb.js`, `events.js`, `switchboard-sdk.js`, `workflow-surfaces.js`) +is fully removed. Two survivors (`debug.js`, `repl.js`) cleaned of all old-layer dependencies +and now stand alone with direct `window.*` exports. + +### Removed + +- `src/js/sb.js` (133 LOC) — global action registry (`window.sb`); onclick handlers + in base.html rewritten to direct function calls +- `src/js/events.js` (361 LOC) — event bus + WebSocket bridge (`window.Events`); + WebSocket auth called deleted `API` global; new SDK (`sw/sdk/events.js`) is the + sole event system +- `src/js/switchboard-sdk.js` (~697 LOC) — composition layer (`window.Switchboard`); + `window.sw` was immediately overwritten by Preact SDK `boot()`; most methods + referenced deleted globals (`API`, `UI`) +- `src/js/workflow-surfaces.js` (481 LOC) — workflow stage surface registry; loaded + deleted `ui-primitives.js` as dependency (already broken at runtime since v0.37.13) +- `base.html`: removed 3 script tags, removed `Switchboard.init()` block + (including dead `handleLogout` referencing deleted `Events`/`API`) + +### Changed + +- `src/js/debug.js` — replaced `sb.ns()`/`sb.register()` with direct `window.*` + exports; replaced dead `API`/`App`/`Events`/`Extensions` state snapshot with + Preact SDK `window.sw` introspection; diagnostics use `sw.auth.token` and + `sw.events.connected`; fixed `copyDebugLog` referencing deleted `UI.toast` +- `src/js/repl.js` — replaced `sb.ns()` with `window.REPL`; admin gate uses + `window.sw.auth.user.role` instead of deleted `API.user.role`; injected globals + reduced to `sw` + `DebugLog`; removed dead event label tab-completion +- `server/pages/templates/base.html` — 11 `sb.call()` onclick handlers rewritten + to direct function calls +- `server/pages/templates/workflow.html` — broken custom surface loader replaced + with graceful error message +- `src/sw.js` — removed 3 entries from SHELL_FILES (sb.js, events.js, + switchboard-sdk.js) +- `packages/icd-test-runner/js/tier-sdk.js` — boot tests updated from + `Switchboard.init()` to Preact SDK `boot()`; identity tests use `sw.auth.user`; + theme test uses `sw.emit()`; removed extension compat shim test +- `src/js/sw/sdk/index.js` — SDK version marker updated to 0.37.14 + +### Design Notes + +- **2 old JS files survive:** `debug.js` (debug modal + diagnostics) and `repl.js` + (admin REPL console). Both are standalone with zero old-layer dependencies. + Scheduled for Preact rewrite at v0.37.17. +- **Cumulative scorched earth:** 53 files deleted, ~−19,382 lines across four + passes (v0.37.10, v0.37.12, v0.37.13, v0.37.14). + +--- + ## [0.37.13] — 2026-03-22 ### Summary diff --git a/FE-REWRITE-REVIEW-0.37.14.md b/FE-REWRITE-REVIEW-0.37.14.md new file mode 100644 index 0000000..72632c8 --- /dev/null +++ b/FE-REWRITE-REVIEW-0.37.14.md @@ -0,0 +1,237 @@ +# FE Rewrite Review — CS-0.37.14 + +**Reviewer:** Claude (Opus 4.6) +**Date:** 2026-03-23 +**Scope:** Full critical review of the Preact+htm frontend rewrite (src/js/sw/) and supporting backend changes (CS-0.37.1 permission enforcement). + +**Stats:** ~16,200 lines of new JS across 115 files, ~6,800 lines of CSS across 17 files. Backend: 2 new handler files, 9 route edits, 12 new integration tests. + +--- + +## Severity Legend + +- **P0** — Breaks functionality or security in a live deployment. Fix before merging. +- **P1** — Significant regression or correctness issue. Fix before tagging. +- **P2** — Design debt or missing wiring. Track for next changeset. +- **P3** — Cleanup, dead code, cosmetic. Fix opportunistically. + +--- + +## P0 — Fix Before Merge + +### 1. Pipe/filter pipeline is wired but never invoked + +`sw.pipe` is fully built (pre/stream/render stages, scoping, stats, dedup detection) but no component calls `_runPre`, `_runStream`, or `_runRender`. The chat completion flow in `use-chat.js` calls `sw.api.channels.complete()` directly. `use-stream.js` parses SSE without running stream filters. Markdown rendering skips render filters entirely. + +**Impact:** Extensions that register pipe filters are silently ignored. The v0.30.0 compaction-as-filter roadmap item cannot work. This is a hard regression from the old FE where the pipe was invoked during completion. + +**Fix:** Wire `sw.pipe._runPre(ctx)` before the completion call in `use-chat.sendMessage()`, `sw.pipe._runStream(ctx)` in the SSE loop in `use-stream.js`, and `sw.pipe._runRender(ctx)` in the markdown rendering path. The calling conventions match the old SDK — the hooks just need to invoke them at the right points. + +**Files:** `src/js/sw/components/chat-pane/use-chat.js`, `src/js/sw/components/chat-pane/use-stream.js`, `src/js/sw/components/chat-pane/markdown.js` + +### 2. User menu RBAC gating is inverted + +```js +if ((!authenticated || sw?.isAdmin) && current !== 'admin') { + list.push({ label: 'Admin', ... }); +} +``` + +The `!authenticated` branch means unauthenticated users see Admin, Team Admin, and Debug menu items. The same inverted pattern appears on all three privileged items. + +**Impact:** Security-facing. Non-admin users see (and can navigate to) admin pages. The backend still enforces `RequireAdmin()` on the route, so it's defense-in-depth, but the menu exposes attack surface. + +**Fix:** Change `!authenticated || sw?.isAdmin` to `sw?.isAdmin` (and similarly for Team Admin/Debug). Remove the `!authenticated` fallback — it was clearly a dev-time bypass. + +**File:** `src/js/sw/shell/user-menu.js`, lines 86–100 + +### 3. `_unwrap` misses bare `{data:[...]}` envelopes + +The REST client strips the `data` array only when pagination fields (`page`, `total`, `per_page`) are present: + +```js +if (json && Array.isArray(json.data) && ('page' in json || 'total' in json || 'per_page' in json)) { + return json.data; +} +``` + +Multiple backend endpoints return `{"data": [...]}` without pagination fields: notes backlinks, notes titles, notes graph, notes search results, channel KBs, and others. These pass through as `{data: [...]}` objects. **24 call sites** defensively work around this with `resp.data || resp || []`. + +**Impact:** The SDK's stated contract — "List methods always return the unwrapped array" — is silently broken. Every consumer must know which endpoints include pagination and which don't. New code will get this wrong. + +**Fix:** Change the condition to `json && Array.isArray(json.data)`. This matches the response envelope convention (`{"data": []}` for all lists). Single-object responses don't have a `data` array, so they pass through correctly. + +**File:** `src/js/sw/sdk/rest-client.js`, `_unwrap` function + +--- + +## P1 — Fix Before Tag + +### 4. `deleteMessage` calls a phantom API + +`use-chat.js` calls `sw.api.channels.deleteMessage(...)` which doesn't exist in `api-domains.js` and has no corresponding backend endpoint in the ICD. The `?.` guard makes it a no-op, but the optimistic UI remove (`setMessages(prev => prev.filter(...))`) means messages disappear from the UI until page refresh, then reappear. + +**Impact:** Users think they deleted a message; they didn't. Data inconsistency. + +**Fix:** Either add the backend endpoint + SDK method, or remove the optimistic UI remove and surface a "not supported" toast. + +**Files:** `src/js/sw/components/chat-pane/use-chat.js` (deleteMessage), `src/js/sw/sdk/api-domains.js` (missing method) + +### 5. Menu submenu positioning is wrong + +The recursive `` for submenus uses `anchor=${menuRef.current}` — the parent menu's root div. The submenu positions relative to the entire parent menu element, not the individual item that triggered it. + +**Impact:** Submenus appear at the parent menu's top-left corner instead of adjacent to the hovered row. + +**Fix:** Track a ref per menu item (or pass the item's DOM element as anchor) so the submenu positions relative to its trigger row. + +**File:** `src/js/sw/primitives/menu.js`, submenu rendering block + +### 6. ChatDashboard recent items are not clickable + +The recent channel list in the empty-state dashboard renders items without an `onClick` handler. Users can see their recent chats but cannot click to navigate to them. + +**Impact:** UX dead end. The dashboard shows data it doesn't act on. + +**Fix:** Add `onClick=${() => onSelect(ch.id, ch.type)}` to the recent item divs. + +**File:** `src/js/sw/surfaces/chat/chat-workspace.js`, `ChatDashboard` component + +### 7. Version string mismatch + +Three different version strings in the same changeset: +- `sw._sdk = '0.37.14'` (sdk/index.js) +- `console.log('[sw] SDK v0.37.9 ready')` (sdk/index.js) +- `VERSION` file: `0.37.14.14` (four segments, breaks `0..` convention) + +**Fix:** Unify to one version string, probably `0.37.14`. Fix the console log. Decide whether the four-segment VERSION is intentional. + +**Files:** `src/js/sw/sdk/index.js`, `VERSION` + +### 8. New FE code has zero CI coverage + +The CI syntax check globs `src/js/*.js` (top-level only), missing all 115 files under `src/js/sw/`. Additionally, `node --check` doesn't work on ES modules with `import` statements. The existing `__tests__` test against old globals, not the new Preact stack. + +**Impact:** Syntax errors, import typos, or broken modules in the new code won't be caught until runtime. + +**Fix:** Add a CI step that either validates ES module syntax (e.g., a lightweight bundler dry-run or a glob that covers `src/js/sw/**/*.js`) or adds basic smoke tests for the new SDK boot path. + +**File:** `.gitea/workflows/ci.yaml`, `test-frontend` job + +--- + +## P2 — Track for Next Changeset + +### 9. `sw.can()` is essentially unused + +Only 1 reference in all surface code (`team-admin/index.js` uses `sw.isAdmin`, not `sw.can()`). No surface gates create/edit/delete buttons with `sw.can('kb.write')`, `sw.can('channel.create')`, etc. The backend permission enforcement from CS-0.37.1 has no client-side counterpart. + +The settings surface does its own policy resolution from `__PAGE_DATA__` + a separate API fallback, rather than reading `sw.auth.policies` which is already cached from `/profile/permissions`. + +**Impact:** The entire client-side RBAC story — the motivating feature for this rewrite — is infrastructure without consumers. Users will see buttons they can't use; the backend returns 403s but the UI doesn't prevent the click. + +**Recommendation:** A follow-up pass through all surfaces to add `sw.can()` gates on action buttons, and migrate settings to use `sw.auth.policies`. + +### 10. Pipe `_runChain` has no Promise guard + +The comment says "Sync-only — filters must not return Promises" but there's no guard. If a filter returns a Promise (which is truthy), it silently replaces `ctx` with the Promise object, corrupting the chain for all downstream filters. + +**Fix:** Add `if (result instanceof Promise) { console.error(...); continue; }` after the filter call. + +**File:** `src/js/sw/sdk/pipe.js`, `_runChain` + +### 11. ChatPane handleRef `useEffect` runs every render + +The `useEffect` that sets `handleRef.current = {...}` has no dependency array, rebuilding the imperative handle object on every render cycle. + +**Fix:** Add `[]` or `[handleRef]` as dependency array. + +**File:** `src/js/sw/components/chat-pane/index.js`, line 66 + +### 12. `__USER__` / `__PAGE_DATA__` injection is mostly dead + +The Go template still injects `window.__USER__` and `window.__PAGE_DATA__` on every page load. The new SDK boots from API calls. Only `settings/index.js` reads `__PAGE_DATA__` (for BYOK/persona policies) and it already has an API fallback. `window.__USER__` is never read by new code. + +**Recommendation:** Remove `__USER__` injection. Migrate settings to use `sw.auth.policies` (see #9), then remove `__PAGE_DATA__` injection. + +### 13. Debug modal is old-world code + +The debug modal in `base.html` (~50 lines of raw HTML with inline `onclick` handlers, `getElementById`, global functions like `switchDebugTab()`) is the largest remaining "no raw DOM" violation and is shared across all surfaces. + +**Recommendation:** Rebuild as a Preact component in a follow-up, or at minimum flag it as intentional legacy. + +--- + +## P3 — Cleanup + +### 14. Dual toast container + +`base.html` still has `
` from the old world. Every surface also renders `` (Preact). The old div is orphaned dead DOM. + +**Fix:** Remove the `#toastContainer` div from `base.html`. + +### 15. Dead code: `esc()` in admin/users.js + +`function esc(s) {...}` is defined but never called. Leftover from a template that got Preact-ified. + +**Fix:** Delete it. + +**File:** `src/js/sw/surfaces/admin/users.js`, line 7 + +### 16. `sidebar-chats.js` reaches into `#surfaceInner` + +`_getScale()` reads `document.getElementById('surfaceInner')` to compensate for CSS zoom on `position: fixed` menus. Technically correct but creates an implicit coupling between a surface component and the shell's DOM structure. + +**Recommendation:** Move scale detection into the SDK (e.g., `sw.shell.getScale()`) so the coupling is explicit. + +### 17. Notes markdown rendering uses raw DOM + +`markdown.js` uses `innerHTML`, `querySelector`, `querySelectorAll` — inherent to the marked+DOMPurify pipeline. `attachWikilinkHandlers` adds click listeners imperatively on every render without explicit cleanup (Preact's DOM replacement handles GC, but the pattern is fragile). + +**Recommendation:** Document this as an intentional exception to the "no raw DOM" principle. Consider a wrapper component that manages the imperative lifecycle. + +### 18. Double-unwrap defensive pattern ~~(24 instances)~~ — **RESOLVED v0.37.14.21** + +~~Once P0 #3 is fixed (`_unwrap` condition change), all 24 instances of `resp.data || resp || []` become dead fallback code.~~ + +**Resolved:** Actually 93 instances (not 24). Fixed `_unwrap` to preserve sibling fields (`Object.keys` check), normalized all 18 non-standard Go handler envelopes to `{data: [...]}`, and simplified all frontend call sites. Also fixed 3 latent bugs (roles data lost, provider types empty, audit entries empty). See CHANGELOG v0.37.14.21. + +### 19. Keyboard shortcuts in use-notes.js use DOM presence check + +`if (document.querySelector('.sw-notes-pane'))` gates Ctrl+N and Ctrl+D shortcuts. This means the shortcuts fire whenever the notes pane exists anywhere in the DOM, not when it's focused. If notes and chat are ever composed on the same page, shortcuts will conflict. + +**Recommendation:** Use a focus-based check or a "surface active" flag from the SDK. + +--- + +## Positives + +### Architecture + +The layer separation is clean and consistently applied. Primitives have zero business logic. The SDK has zero DOM. Shell has zero API calls. Surfaces compose from both. This matches the design doc precisely. The factory pattern for all SDK modules avoids singleton coupling — every module is testable in isolation. + +### SDK internals + +The auth module handles token persistence with cookie sync for Go SSR, proactive refresh at 80% expiry, coalesced refresh promises (preventing thundering herd on concurrent 401s), and a clean boot sequence with offline tolerance. The event bus correctly classifies local-only events to prevent UI concerns from crossing the WebSocket. The CRUD factory in `api-domains.js` eliminates boilerplate across 18 domain namespaces. + +### Performance + +The `use-stream.js` hook uses rAF-batched state updates during SSE parsing. Content accumulates in refs and flushes at display frame rate, preventing re-render storms during fast token output. This is the correct pattern for streaming LLM output into a reactive UI. + +### Developer experience + +The crash catcher in surface templates (`window.addEventListener('error', ...)` with a visible banner) is a smart production debugging aid for a scorched-earth rewrite. The admin surface's lazy section loading with version-busted dynamic imports means sections load on demand and cache-bust on upgrade. The `sw.chatPane()` and `sw.notesPane()` imperative render helpers let extension surfaces mount chat/notes without knowing Preact internals. + +### Backend (CS-0.37.1) + +The permission handler correctly gives admins `auth.AllPermissions` and resolves the full permission set for non-admins. The 12 new integration tests use a deny→grant→allow pattern that doesn't depend on the Everyone group's seed data. The route audit is thorough — 8 routes fixed, 18 verified correct, and the ICD is updated. + +--- + +## Recommended Merge Order + +1. Fix P0 #1–3 (pipe wiring, user menu RBAC, `_unwrap` condition) +2. Fix P1 #4–8 (phantom API, submenu anchor, dashboard clicks, version, CI) +3. Merge and deploy to dev +4. Track P2 items for v0.37.15 or v0.38.0 +5. Clean up P3 items opportunistically diff --git a/ICD-DRIFT-AUDIT.md b/ICD-DRIFT-AUDIT.md new file mode 100644 index 0000000..45f6685 --- /dev/null +++ b/ICD-DRIFT-AUDIT.md @@ -0,0 +1,95 @@ +# ICD Drift Audit — SDK vs Backend + +**Date:** 2026-03-23 +**Method:** Compared `src/js/sw/sdk/api-domains.js` against `docs/ICD/*.md` and `server/main.go`. +**Principle:** ICD is the zone of truth. SDK must conform to it. + +--- + +## A. SDK Bugs — Wrong method or path (fix in SDK) + +| SDK Method | SDK Calls | Backend Route | Issue | +|---|---|---|---| +| `workflows.update(id, data)` | `PUT /workflows/:id` | `PATCH /workflows/:id` | `crud()` generates PUT; backend is PATCH → 404/405 | +| `workspaces.update(id, data)` | `PUT /workspaces/:id` | `PATCH /workspaces/:id` | Same — `crud()` generates PUT; backend is PATCH | +| `tasks.start(id, data)` | `POST /tasks/:id/start` | `POST /tasks/:id/run` | Path mismatch → 404 | +| `tasks.stop(id)` | `POST /tasks/:id/stop` | `POST /tasks/:id/kill` | Path mismatch → 404 | +| `dataPortability.deleteAccount(data)` | `POST /profile/delete` | `DELETE /me` | Both method and path wrong | + +## B. SDK Bug — Duplicate key overwrites (fix in SDK) + +`notifications` is defined **three times** as a top-level key in the `createDomains` return object: + +1. **Line 186** — Domain #11 (has `prefs`, `setPref`, `delPref`, uses `POST` for markRead) +2. **Line 431** — Inside `admin` namespace (correct, no conflict) +3. **Line 501** — "Misc" section (uses `PATCH` for markRead, no preference methods) + +JavaScript last-write-wins: #3 overwrites #1. The notification preference methods (`prefs`, `setPref`, `delPref`) are silently lost. The `markRead` method changes from POST to PATCH (PATCH matches the ICD, so #3 has the correct HTTP method but is missing methods). + +**Fix:** Delete the duplicate at line 501. Merge its `del` method and `PATCH` fix into the domain #11 block at line 186. + +## C. ICD Gaps — Endpoints exist in main.go, not documented in ICD + +These endpoints are real (registered in main.go, used by the SDK) but have no ICD documentation. + +| Endpoint | Belongs in ICD File | Notes | +|---|---|---| +| `POST /channels/:id/mark-read` | channels.md | SDK: `channels.markRead()` | +| `POST /channels/:id/generate-title` | channels.md | SDK: `channels.generateTitle()` | +| `POST /channels/:id/summarize` | channels.md | SDK: `channels.summarize()` | +| `GET /channels/:id/messages/:msgId/siblings` | channels.md | SDK: `channels.siblings()` | +| `GET /knowledge-bases-discoverable` | knowledge.md | SDK: `knowledge.discoverable()` | +| `GET /folders`, `POST`, `PUT /:id`, `DELETE /:id` | (new section) | Full CRUD, needs own ICD section or add to channels.md | +| `GET /users/search?q=...` | (new section) | SDK: `users.search()` | +| `POST /presence/heartbeat`, `GET /presence` | (new section) | SDK: `presence.heartbeat()` | +| `GET /usage` | (new section) | SDK: `usage.mine()` | +| `GET /groups/mine` | (new section) | SDK: `groups.mine()` | +| `GET /export/me` | utility.md | SDK: `dataPortability.exportMe()` | +| `DELETE /me` | utility.md | GDPR account deletion | +| `GET /git-credentials`, `POST`, `DELETE /:id` | (new section) | SDK: `git.credentials.*` | +| `POST /git-credentials/generate` | (new section) | Not in SDK — SSH key generation | +| `GET /git-credentials/:id/public-key` | (new section) | Not in SDK | +| `GET /tools` | (new section) | SDK: `tools.list()` | + +## D. ICD-Documented Endpoints Missing from SDK + +In the ICD but the SDK doesn't expose them. Add only as surfaces need them. + +| Endpoint | ICD File | Notes | +|---|---|---| +| Workflow stages CRUD (`GET/POST/PUT/DELETE /workflows/:id/stages/*`) | workflows.md | Workflow editor needs these | +| `PATCH /workflows/:id/stages/reorder` | workflows.md | | +| `POST /workflows/:id/publish` | workflows.md | Team-scoped version exists in SDK | +| `POST /workflows/:id/start` | workflows.md | Instance creation | +| `GET /channels/:id/workflow/status` | workflows.md | | +| `POST /channels/:id/workflow/advance` | workflows.md | | +| Workspace archive (`download`, `upload`) | workspaces.md | | +| `POST /workspaces/:id/reconcile` | workspaces.md | | +| `GET /workspaces/:id/stats` | workspaces.md | | +| `GET /workspaces/:id/index-status` | workspaces.md | | +| `POST /workspaces/:id/git/clone`, `git/pull` | workspaces.md | | +| `GET /extensions/:id/manifest` | extensions.md | | +| `GET /extensions/tools` | extensions.md | | +| `POST /personas/:id/avatar`, `DELETE` | personas.md | User-scoped (admin-scoped exists) | +| `POST /knowledge-bases/:id/rebuild` | knowledge.md | | + +## E. Response Shape Mismatches + +| Endpoint | Response Shape | Convention | Notes | +|---|---|---|---| +| `GET /api-configs` | `{"configs": [...]}` | Should be `{"data": [...]}` | SDK `_unwrap` won't strip | +| `GET /admin/configs` | `{"configs": [...]}` | Should be `{"data": [...]}` | Same | +| `GET /admin/models` | `{"models": [...]}` | Should be `{"data": [...]}` | Same | +| `GET /admin/users` | `{"users": [...]}` | Should be `{"data": [...]}` | Same | + +These use named keys instead of the `{"data": [...]}` envelope convention. The SDK's `_unwrap` doesn't strip these, so consumers get the envelope object. + +--- + +## Recommended Priority + +1. Fix §A (SDK 404s) — these are broken in production +2. Fix §B (duplicate notifications key) +3. Document §C (ICD gaps) — endpoints work, just undocumented +4. Add §D methods to SDK — only as surfaces need them +5. Migrate §E response shapes to `{"data": [...]}` — breaking change, defer diff --git a/TURNOVER-0.37.14-S8.md b/TURNOVER-0.37.14-S8.md new file mode 100644 index 0000000..3d12658 --- /dev/null +++ b/TURNOVER-0.37.14-S8.md @@ -0,0 +1,129 @@ +--- +**Turnover — changeset-0.37.14 Session 8** +**Version:** 0.37.14.20 | 3 commits (2 yours + 1 triage) | PR #226 +**Branch:** `changeset-0.37.14` | Commit: `e2481a2` + +--- + +## Commits this session + +- `b8e03c4` — Upload 3 new, 3 modified files (profile bootstrap, ICD drift audit) +- `0ceb20a` — Delete Critical Review.md (renamed to FE-REWRITE-REVIEW-0.37.14.md) +- `e2481a2` — Critical Review triage: RBAC fix, _unwrap, pipe wiring, menu/dashboard, CI + +## What was done (triage commit) + +### P0 Fixes (3) — Fix Before Merge + +1. **RBAC inversion** (1 file, 3 lines) — `user-menu.js` lines 85/91/96: `!authenticated || sw?.isAdmin` was inverted, showing Admin/Team Admin/Debug to unauthenticated users. Fixed to `authenticated && sw?.isAdmin`. Backend already enforced 403, but UI now correctly gates visibility. + +2. **`_unwrap` SDK contract** (1 file, 1 line) — `rest-client.js` line 101: condition required pagination fields (`page`, `total`, `per_page`) to unwrap `{data: [...]}` envelopes. 24 call sites defensively did `resp.data || resp || []`. Relaxed to just check `Array.isArray(json.data)`. + +3. **Pipe/filter pipeline wiring** (3 files) — `sw.pipe._runPre/Stream/Render` was fully built but never invoked. Wired into: + - `use-chat.js`: pre-send filter before completion call (can block/transform) + - `use-stream.js`: per-chunk stream filter in SSE loop + - `markdown.js`: render filter after sanitization + @mention highlighting + +### P1 Fixes (5) — Fix Before Tag + +4. **deleteMessage phantom API** (1 file) — No server DELETE `/channels/:id/messages/:msgId` route exists. Removed `onDelete` prop from ChatPane so delete button is hidden until backend is wired. + +5. **Menu submenu positioning** (1 file) — `menu.js`: submenu anchored to parent menu root div instead of triggering item. Added `itemRefs` map, `setSubmenu` now stores `{ item, anchorEl }`, submenu renders at correct position. + +6. **Dashboard recent items** (1 file) — `chat-workspace.js`: added `onNavigate` prop to ChatDashboard, recent items now have `onClick` handler + cursor pointer. + +7. **Version string mismatch** (1 file) — `sdk/index.js` line 164: hardcoded `'v0.37.9'` replaced with template literal `` `v${sw._sdk}` ``. Console now shows correct version. + +8. **CI frontend coverage** (1 file) — `ci.yaml`: glob changed from `src/js/*.js` (top-level only) to `find src/js -name '*.js'` (recursive). Uses `--input-type=module` for ES module syntax checking. + +### P2/P3 Quick Fixes (4) + Additional (2) + +9. **Pipe Promise guard** — `pipe.js` `_runChain`: detects async filters returning Promises, logs error + increments error counter, continues chain. + +10. **useEffect no-deps documented** — `chat-pane/index.js` line 66: added comment explaining intentional missing dependency array (handle must reference latest closures). + +11. **Dual toast container** — `base.html`: removed orphaned `#toastContainer` div (Preact toast primitive creates its own). + +12. **Dead `esc()` function** — `admin/users.js` line 7: deleted unused function. + +13. **WS message deduplication** — `use-chat.js` line 288: added `prev.some(m => m.id && m.id === msg.id)` check before appending from WebSocket. Prevents duplicates on reconnect. + +14. **Optimistic message key stability** — `use-chat.js` line 103: optimistic user messages now get `id: 'tmp-' + Date.now()` so Preact key is always stable. + +### Roadmap Updates + +- `docs/ROADMAP.md` v0.37.17 row: added "debug modal Preact rebuild (CR P2-5)" +- `docs/ROADMAP.md` v0.37.18 row: added "sw.can() gates (P2-1), __USER__/__PAGE_DATA__ removal (P2-4), _getScale SDK (P3-3)" +- `FE-REWRITE-REVIEW-0.37.14.md`: P0/P1 sections marked resolved, P2/P3 sections annotated with version assignments. + +## Files changed (+353/−35, 21 files) + +| File | Change | +|---|---| +| src/js/sw/shell/user-menu.js | ±6 RBAC condition fix | +| src/js/sw/sdk/rest-client.js | ±1 _unwrap condition | +| src/js/sw/components/chat-pane/use-chat.js | +11/−2 pipe pre, dedup, temp ID | +| src/js/sw/components/chat-pane/use-stream.js | +9/−1 pipe stream filter | +| src/js/sw/components/chat-pane/markdown.js | +12 pipe render, doc comment | +| src/js/sw/components/chat-pane/index.js | ±4 remove onDelete, useEffect doc | +| src/js/sw/primitives/menu.js | +16/−7 submenu anchor refactor | +| src/js/sw/surfaces/chat/chat-workspace.js | +8/−3 dashboard onClick, onNavigate | +| src/js/sw/sdk/index.js | ±1 version template literal | +| src/js/sw/sdk/pipe.js | +6 Promise guard | +| src/js/sw/surfaces/admin/users.js | −2 dead code | +| server/pages/templates/base.html | −1 orphaned toast div | +| .gitea/workflows/ci.yaml | +14/−4 recursive glob + ES module | +| docs/ROADMAP.md | ±4 v0.37.17/18 deferred items | +| FE-REWRITE-REVIEW-0.37.14.md | ±10 resolution annotations | +| VERSION | 0.37.14.20 | +| server/handlers/profile_bootstrap.go | +117 (your commit) | +| server/main.go | +4 (your commit) | +| server/handlers/integration_test.go | +4 (your commit) | +| ICD-DRIFT-AUDIT.md | +95 (your commit) | +| docs/ICD/profile.md | +59 (your commit) | + +## Verification results + +| Check | Result | +|---|---| +| Admin menu items visible for admin | PASS | +| Channel messages load (unwrap working) | PASS | +| Dashboard recent items clickable | PASS | +| Delete button hidden | PASS | +| Console version: `SDK v0.37.14` | PASS | +| Zero console errors | PASS | +| @mentions rendered correctly | PASS | +| Docker build + server start | PASS | + +## Deferred items — version mapping + +| Item | Assigned To | Description | +|---|---|---| +| P3-5: Double-unwrap cleanup | **DONE v0.37.14.21 (S9)** | 93 patterns (not 24) cleaned; API envelope normalization; 3 bug fixes | +| P2-5: Debug modal rebuild | **v0.37.17** | Raw HTML + inline onclick in base.html → Preact component | +| P2-1: sw.can() RBAC gates | **v0.37.18** | Gate action buttons with `sw.can()` across all surfaces | +| P2-4: __USER__/__PAGE_DATA__ | **v0.37.18** | Remove dead Go template injection, migrate settings to sw.auth.policies | +| P3-3: _getScale SDK | **v0.37.18** | Move sidebar DOM coupling to `sw.shell.getScale()` API | +| P3-4: Notes markdown raw DOM | **documented** | Intentional for perf, comment added in markdown.js | + +## Known issues (carried forward) + +1. **Thinking tags lost on refresh** — `` content spills into message body after reload. DB stores raw, load path doesn't extract like streaming path does. +2. **Model settings select/unselect all** — no bulk toggle. Deferred to per-provider model management UI. +3. **Double-unwrap cleanup** — 24 call sites still have defensive `resp.data || resp || []`. Safe now but redundant. Next session. + +## Open backlog + +1. Double-unwrap cleanup (24 sites, next session) +2. Thinking tags persistence across refresh +3. ICD remaining failures (Venice stream, knowledge 403-vs-404) +4. Extension surface "? User" label +5. Admin panel audit +6. Model qualified display + per-provider management UI +7. SDK gaps (20+ missing endpoints) +8. Team-admin surface audit +9. Settings surface audit +10. Notes surface audit +11. deleteMessage server endpoint (server route + handler needed) + +--- diff --git a/VERSION b/VERSION index fa6037a..1ab5ac2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.37.13 +0.37.14.23 diff --git a/docker-compose.yml b/docker-compose.yml index 922a459..3e828a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,8 @@ services: CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000} LOG_FORMAT: ${LOG_FORMAT:-text} LOG_LEVEL: ${LOG_LEVEL:-info} + # Dev seed users — ignored if ENVIRONMENT=production + SEED_USERS: ${SEED_USERS:-alice:password123:user,bob:password456:user,charlie:password789:user} volumes: - ./data:/data ports: diff --git a/docs/ICD/admin.md b/docs/ICD/admin.md index fe3b243..0e5f7b9 100644 --- a/docs/ICD/admin.md +++ b/docs/ICD/admin.md @@ -50,12 +50,15 @@ GET /settings/public "allow_registration": "true", "allow_user_byok": "true", "allow_user_personas": "true", - "channel_retention_mode": "flexible" + "retention_ttl_days": 0 } } ``` Note: policy values are strings (`"true"` / `"false"`), not booleans. +`retention_ttl_days` is an integer (days). When > 0, channels using +global/team providers are archived on delete and purged after TTL. +Personal (BYOK) provider channels are always immediately deleted. ### Banner Configuration @@ -202,7 +205,7 @@ admin's own email address using the configured SMTP settings. ### Archived Channels ``` -GET /admin/channels/archived → { "channels": [...], "total", "page", "per_page", "retention_mode" } +GET /admin/channels/archived → { "channels": [...], "total", "page", "per_page", "retention_ttl_days" } DELETE /admin/channels/:id/purge → permanent delete (ignores retention) ``` diff --git a/docs/ICD/channels.md b/docs/ICD/channels.md index da787ff..895fafc 100644 --- a/docs/ICD/channels.md +++ b/docs/ICD/channels.md @@ -116,10 +116,34 @@ Same field set as create, plus `is_archived`, `is_pinned`, `settings` DELETE /channels/:id ``` -Cascades: deletes messages, files, channel_models, channel_kbs, +**Retention policy (v0.37.14):** When `retention_ttl_days > 0`, all +channels are archived on delete and purged after the TTL. The only +exception is channels using a Personal (BYOK) provider — those are +always hard-deleted immediately. + +| Provider scope | TTL > 0 | TTL = 0 | +|---------------|---------|---------| +| `personal` (BYOK) | Hard delete | Hard delete | +| `global` | Archive + purge after TTL | Hard delete | +| `team` | Archive + purge after TTL | Hard delete | +| NULL (no provider) | Archive + purge after TTL | Hard delete | + +When retention applies, the channel is archived (`is_archived = true`) +and stamped with `purge_after`. A background scanner purges channels +past their `purge_after` hourly. Archived channels are hidden from +the user's sidebar. + +Non-owners who call DELETE are removed as participants ("leave channel") +instead of deleting. + +**Response (immediate delete):** `{"message": "channel deleted"}` +**Response (retention):** `{"message": "channel archived for retention", "purge_after": "..."}` +**Response (leave):** `{"message": "left channel"}` + +Hard-delete cascades: messages, files, channel_models, channel_kbs, channel_participants. Storage cleanup runs asynchronously. -**Auth:** Owner only. +**Auth:** Owner for delete/archive; any participant for leave. ### Message Tree diff --git a/docs/ICD/profile.md b/docs/ICD/profile.md index 4f67609..50d7ab1 100644 --- a/docs/ICD/profile.md +++ b/docs/ICD/profile.md @@ -248,3 +248,62 @@ This endpoint is the SDK's bootstrap source for `sw.can(permission)`. Called once at login and on each token refresh. --- + +### Bootstrap (v0.37.15) + +``` +GET /profile/bootstrap +``` + +**Auth:** Authenticated user + +Single-call boot payload for the SDK. Collapses what previously required +3–4 sequential requests (profile, permissions, teams/mine, settings) into +one call. The SDK calls this at startup and on token refresh. + +**Response:** + +```json +{ + "user": { + "id": "uuid", + "username": "jdoe", + "display_name": "Jane Doe", + "email": "jdoe@example.com", + "role": "user", + "avatar": "data:image/png;base64,..." + }, + "permissions": ["channel.create", "kb.read", "model.use"], + "groups": ["group-id-1", "00000000-0000-0000-0000-000000000001"], + "teams": [ + { "id": "uuid", "name": "Engineering", "my_role": "member" } + ], + "policies": { + "allow_user_byok": false, + "allow_user_personas": false, + "allow_raw_model_access": true, + "kb_direct_access": true + }, + "settings": { + "theme": "dark", + "editor_keybindings": "vim" + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `user` | object | Curated profile (same fields as `GET /profile` minus timestamps) | +| `permissions` | string[] | Resolved permission set (sorted). Admins get all. | +| `groups` | string[] | Contributing group IDs (always includes Everyone) | +| `teams` | object[] | Active team memberships with `my_role` | +| `policies` | object | Boolean policy flags for UI feature gating | +| `settings` | object | User preferences (`{}` if never saved) | + +`user.avatar` is omitted when unset (not `null`). + +This is a **superset** of `GET /profile/permissions` — it includes +everything that endpoint returns plus `user` and `settings`. Clients +that only need permissions can continue using the narrower endpoint. + +--- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 47d00c9..33a740e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -48,8 +48,8 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅ │ v0.37.2 Primitives ✅ │ │ v0.37.3 SDK │ │ v0.37.4 Shell │ - │ v0.37.5–13 Surfaces ✅ │ - │ v0.37.14–18 Surfaces │ + │ v0.37.5–14 Surfaces ✅ │ + │ v0.37.15–18 Surfaces │ │ │ │ ══════╪════════════╪═════════════════╪══════ │ MVP v0.50.0 │ @@ -62,475 +62,19 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅ STT/TTS, desktop app) ``` -## Completed: v0.28.0 — Platform Polish - -Audit arc, frontend decomposition, security, infrastructure. Eight -sub-versions, all complete. See CHANGELOG.md for detailed release notes. - -| Version | Summary | Key Deliverables | -|---------|---------|------------------| -| v0.28.1 | Surfaces ICD Audit ✅ | 6 ICD fixes, 19 E2E tests, 36 Go tests | -| v0.28.2 | ICD Audit: All Domains ✅ | 469/469 (100%), full methodology documented | -| v0.28.3 | ICD Close-out + FE Decomp ✅ | WebSocket ICD rewrite, 47 JS files → ES modules, `sb.js` registry | -| v0.28.4 | Security Tier ✅ | 58 red-team tests, 5 real bugs found+fixed, JWT role from DB | -| v0.28.5 | Frontend SDK + Pipes ✅ | `switchboard-sdk.js`, 3-stage pipe/filter pipeline, 36 SDK tests | -| v0.28.6 | Infrastructure ✅ | Virtual scroll, Helm chart, system tasks, git keygen, broadcast | -| v0.28.7 | Unified Packaging ✅ | `.pkg` format, `packages` table, task RBAC, `task.starlark` gate | - -**Deferred from v0.28.x (relocated with version pins):** -- `sw.notes()` factory → v0.30.1 -- Phase 5 FE decomp (`import`/`export`) → v0.30.1 -- Cross-visitor isolation E2E test → v0.29.3 -- Admin UI task permission management → v0.29.0 -- ICD runner packaging test tier → v0.29.0 - ---- - -## v0.28.8 — ICD Green Board ✅ - -Close out pre-existing ICD test failures and infrastructure issues -discovered during the green board push. - -Depends on: v0.28.7 (unified packaging). - -**Root cause analysis (discovered during deployment):** - -The 502/503 cascade that failed 8–10 ICD tests across provider, BYOK, -and SDK tiers was caused by **OOMKilled** — the dev backend pod had a -256Mi memory limit. The Go process peaked at ~216Mi during the 569-test -ICD suite and exceeded 256Mi during SSE completion streams, triggering -exit code 137. Traefik returned `503 no available server` until the pod -restarted (~15s). - -Memory profile (measured on cluster via `kubectl top`): -- 17Mi idle after fresh start -- 216Mi peak during full ICD suite (569 requests, SSE streams) -- 60Mi post-test (Go GC ran, heap arena retained) -- 34Mi settled (~3 min, OS reclaimed freed pages via MADV_DONTNEED) -- Zero leak — memory returns to baseline, flat steady state - -**Infrastructure fixes:** -- [x] Backend memory limits: dev/test 128Mi/256Mi → 256Mi/512Mi -- [x] Batched `UpsertFromSync`: single transaction, prepared - `INSERT ON CONFLICT DO UPDATE` (300 round-trips → 2, PG + SQLite) -- [x] DB connection lifecycle: `SetConnMaxLifetime(5m)` + - `SetConnMaxIdleTime(1m)` -- [x] HTTP transport pool: `sync.Map` keyed by proxy config -- [x] Provider sync timeout: `context.WithTimeout(30s)`, 504 on timeout -- [x] ICD runner: `apiPostRetry`, environment-aware CORS test, - ticket exchange verification test - -**Security transport:** -- [x] CORS startup warning, `GetAllowedOrigins()`, WebSocket `CheckOrigin` -- [x] WebSocket ticket exchange: `POST /api/v1/ws/ticket`, `WsAuth` - middleware, `TicketStore` with TTL reaper -- [x] `events.js` async ticket-first auth with legacy fallback - -**Kubernetes:** -- [x] Traefik retry `Middleware` CRD + Helm template -- [x] RBAC for Gitea runner → `traefik.io` middleware resources -- [x] CI non-fatal middleware apply with post-success ingress annotation - ---- - -## Extension Track - -Sequential. Each version builds on the previous. Delivers the package -ecosystem, workflow capabilities, and SDK-based surface architecture. - -### v0.29.0 — Starlark Sandbox + Permission Model ✅ - -Server-side extension runtime. Eval loop, permission pipeline, -pre-completion filter chain, and admin review workflow. - -Depends on: v0.28.8. - -**Phase 0 — Store cleanup (prerequisite): ✅** -- [x] Raw SQL hunt: all ~242 `database.DB.*` calls outside `store/` - migrated to store interface methods (CS0–CS7b, 12 changesets) -- [x] CI green on both PG and SQLite pipelines -- [x] ICD runner: 579/580 pass, 1 expected skip -- [x] Documented exception: `events/pg_broadcast.go` (`pg_notify`, - PG-only, no store abstraction needed) - -**Phase 1 — Starlark runtime: ✅** -- [x] Pre-completion filter chain: composable `PreCompletionFilter` - interface + `Chain` registry. KB auto-inject refactored as first - built-in filter. Extension filters register at order 100+ (CS0) -- [x] `go.starlark.net` integration: sandboxed eval with step limits - (1M ops default), context timeout, captured print output, - disabled `load()`. `MakeModule` helper for Go→Starlark (CS1) -- [x] Permission model: `extension_permissions` table (in 016), - `status` column on `packages` (`active`/`pending_review`/ - `suspended`). Manifest `"permissions"` array parsed on install. - Admin review, grant, revoke, grant-all endpoints (CS2) -- [x] Runtime enforcement: `Runner.buildModules()` injects only - granted modules into sandbox namespace (CS3) -- [x] Extension lifecycle: `install → pending_review → grant-all → - active`, revoke → `suspended`. Auto-transitions on grant/revoke. -- [x] Initial modules: `secrets` (GlobalConfig-backed, per-package - key-value store, admin CRUD), `notifications` (wraps - notification service, `send(user_id, title, body?, type?)`) (CS3) -- [x] `task_type: "starlark"`: executor `executeStarlark` loads - package by ID, calls `on_run()` entry point via runner. - RBAC gate `task.starlark` enforced. `system_function` field - holds package ID (CS4) -- [x] KB auto-injection: server-side pre-completion filter chain. - Reference implementation for the filter model Starlark - extensions mirror via `on_pre_completion(ctx)` (CS0+CS3) -- [x] Starlark filter discovery: `DiscoverStarlarkFilters` scans - active packages with `filters.pre_completion` grant (CS3) -- [x] ICD runner: `packaging` test tier — 18 tests covering - permission lifecycle + secrets CRUD (CS5) - -### v0.29.1 — API Extensions ✅ - -Starlark route handlers. Surfaces serve custom JSON endpoints. - -Depends on: v0.29.0. - -- [x] `api_routes` manifest key, mounted at `/s/{id}/api/...` -- [x] Starlark request/response primitives -- [x] `http` outbound module with allowlist/blocklist -- [x] `requires_provider` manifest key (provider resolution via BYOK chain) -- [x] `capability_match` routing policy (cheapest model with required caps) -- [x] Config-file provider types (JSON, no code deploy) - -**Deferred to v0.29.2:** -- Server-side tool execution in completion handler (requires tool - registry integration with sandbox; aligns with DB extensions scope) - -### v0.29.2 — DB Extensions ✅ - -Namespaced tables for extension data. Structured API, not raw SQL. -Server-side tool execution (deferred from v0.29.1) included. - -Depends on: v0.29.1. - -- [x] `ext_{id}_*` tables, dialect-correct DDL (PG + SQLite) -- [x] `db` Starlark module: structured `query/insert/update/delete/list_tables/view` - (structured API instead of raw `exec()` — prevents SQL injection) -- [x] Views as read contract over platform tables (`ext_view_users`, `ext_view_channels`) -- [x] Schema creation on install, drop on uninstall -- [x] Server-side tool execution in completion handler (deferred from v0.29.1) - -### v0.29.3 — Workflow Forms ✅ - -`form_template` renders as real UI. LLM is optional for data collection. - -Depends on: v0.29.2, v0.29.0 (Starlark validators). - -- [x] Typed `form_template` schema (`text`, `email`, `select`, `number`, - `date`, `textarea`, `checkbox`, `file`) with validation rules -- [x] Stage renders as form when `form_template` has typed fields -- [x] LLM-optional stages: form-only, form+chat, chat-only -- [x] Starlark `validate` / `on_submit` hooks -- [x] Visitor form entry (branded page, no chat widget) -- [x] Form builder in workflow admin (visual field editor) -- [x] Cross-visitor isolation E2E test (deferred from v0.28.4) - -### v0.30.0 — Package Lifecycle ✅ - -Lifecycle sophistication for `.pkg` format. - -Depends on: v0.29.2. - -- [x] Schema versioning + migrations in manifest -- [x] Settings extension point (packages declare settings sections) -- [x] Export/import format for cross-instance sharing -- [x] Package marketplace (discovery, not hosting) -- [x] User-installable packages (RBAC-gated, team/personal scope) - -### v0.30.1 — SDK Adoption ✅ - -Migrate core surfaces to `sw.*` SDK. - -Depends on: v0.28.5, v0.30.0. - -- [x] `sw.notes()`, `sw.chat()`, `sw.panels()` real factories -- [x] Core surface migration: chat, editor, notes, settings -- [x] Phase 5 FE decomp: `import`/`export` statements -- [x] Memory compaction: summarize, confidence decay, prune - -### v0.30.2 — Workflow Packages ✅ - -Workflows as installable packages. Visual builder for team admins. - -Depends on: v0.29.3, v0.30.0, v0.30.1. - -- [x] Stage surfaces: form, chat, review, custom `.pkg` -- [x] `.pkg` type `"workflow"` bundles definition + surfaces + handlers -- [x] Team admin workflow builder (visual, no JSON editing) -- [x] `sw.workflow` SDK namespace -- [x] ICD test fixes (auth rate limiter burst 30→8, vault UEK pre-warm) -- [x] Starlark `workflow.*` module (get_definition, get_stage_data, advance, reject) -- [x] Workflow package export/import (.pkg round-trip) -- [x] E2E tests: export/import, surface_pkg_id persistence - -### v0.31.0 — Editor Package + SDK Composability ✅ - -E2E proof: rebuild editor as installable `.pkg`. Zero platform -special-casing. Full SDK composability — every UI piece created -through `sw.*` factories, no component duplication. - -Depends on: v0.30.2. - -**Editor Package (CS0–CS2):** -- [x] Editor `.pkg` (type: `full`), settings via extension point -- [x] State persistence via localStorage (per-user, per-workspace UI state) -- [x] Remove editor from core (`surface-editor` template, data loader) -- [x] E2E tests: install, settings, export/import round-trip, core removal -- [x] Self-mounting components (`Component.mount()` is canonical entry point) - -**SDK Composability (CS3–CS4):** -- [x] Delete `NoteEditor` — `NotePanel` is single canonical notes component -- [x] Platform scripts in `base.html` (chat-pane, note-panel, note-graph available to all surfaces) -- [x] `UserMenu.mount()` — self-contained, works in any container -- [x] `sw.userMenu()` — mount user menu anywhere, `flyout: 'up'|'down'` -- [x] `sw.chat()` uses `ChatPane.mount()` (not manual DOM + `.create()`) -- [x] `sw.fileTree()`, `sw.codeEditor()`, `sw.layout()` SDK wrappers -- [x] `sw.menu()`, `sw.dropdown()`, `sw.toolbar()`, `sw.tabs()` UI primitives -- [x] `--bg-elevated` / `--border-elevated` CSS tokens for floating panels -- [x] Editor package uses SDK exclusively (`sw.layout`, `sw.fileTree`, `sw.codeEditor`, `sw.chat`, `sw.notes`, `sw.userMenu`) -- [x] Nginx caching: JS/CSS use `must-revalidate` (not `immutable`) for dev reload safety -- [x] NotePanel: pagination, select mode, sticky selection bar, `note-panel-root` class (no PanelRegistry conflict) - -**Known remaining (visual polish, not blocking):** -- [x] UserMenu flyout contrast on very dark backgrounds — fixed in v0.31.1 CS0+CS1 -- [x] Editor chat pane duplicates streaming/model-selector logic — absorbed into ChatPane in v0.31.1 CS2 - - -### v0.31.1 — SDK Exercise Surface ✅ - -Build a dashboard surface package exercising every `sw.*` primitive -with zero component CSS overrides. Fix 4 SDK bugs first. - -Depends on: v0.31.0. - -**SDK Bug Fixes (CS0–CS2):** -- [x] Flyout unification — 3 competing CSS systems consolidated into `.sw-menu-flyout` in primitives.css -- [x] Flyout positioning — `position:fixed` escape hatch for overflow-clipped extension surfaces -- [x] Menu unification — UserMenu uses `.sw-menu-flyout` + `data-position`, same as `sw.menu()` -- [x] ChatPane self-contained — standalone mode with streaming, model selector, history (editor slimmed ~220 lines) - -**Dashboard Package (CS3–CS4):** -- [x] `packages/dashboard/` — type "full", route `/s/dashboard` -- [x] Exercises all 14 primitives: userMenu, menu, toolbar, tabs, dropdown, chat, notes, modal, confirm, toast, on/emit, api, user/isAdmin, theme -- [x] Layout-only CSS — zero references to SDK component classes -- [x] E2E tests: install, settings, export/import round-trip (7 tests) - -### v0.31.2 — Team Workflow Self-Service ✅ - -Close the gap: team admins can create and manage workflows for their -team without requiring platform admin access. Clean public URLs via -team slugs (`/w/engineering/intake`). - -Depends on: v0.31.1. - -**CS0 — Backend: Team-Scoped Workflow Routes + Team Slugs:** -- [x] `/teams/:teamId/workflows` route group behind `RequireTeamAdmin` -- [x] CRUD: GET (list), POST (create, inject team_id), PATCH (update), DELETE -- [x] Stage CRUD: GET, POST, PUT, DELETE, PATCH reorder — scoped to team workflows -- [x] Publish: `POST /teams/:teamId/workflows/:id/publish` -- [x] Ownership guard: all mutating ops verify `workflow.team_id == :teamId` -- [x] Reuse existing `WorkflowHandler` methods — team ID injection, not new logic -- [x] Team `slug` column — auto-generated from name, UNIQUE, `GetBySlug` store method -- [x] Workflow entry + page renderer resolve scope via team slug (UUID fallback) -- [x] Auth rate limiter burst 8→5 - -**CS1 — Frontend: Workflows Tab in Team Admin:** -- [x] 9th tab "Workflows" in team admin modal -- [x] List team workflows with status badge (active/inactive) -- [x] Create/edit workflow form (name, slug, entry_mode, branding, retention) -- [x] Stage builder (add/reorder/delete stages, persona picker, history_mode) -- [x] Publish button with version display -- [x] Adapted from platform admin workflow UI (`_loadAdminWorkflows` reference) -- [x] Public URL display uses team slug (`/w/engineering/intake`) -- [x] ICD runner: team workflow CRUD tests, package settings fix, SSE reasoning_content fix - -**Future (not blocking):** -- Team admin modal → full surface package (when tab count justifies it) - ---- - -## Operations Track - -Parallel to extension track. No Starlark dependency. Delivers -production readiness for the target multi-team deployment. - -### v0.32.0 — Multi-Replica HA ✅ - -Run 2–3 backend replicas across nodes for node-level availability. - -Depends on: v0.28.8. - -**Design decision:** PG `SKIP LOCKED` replaces Kubernetes Lease-based -leader election. Every replica runs the scheduler poll loop; PG -serializes task claims atomically. No K8s API dependency, no Redis, -no new coordination infrastructure. See `docs/DESIGN-0.32.0.md`. - -**What already works multi-replica:** -- REST API (stateless, JWT auth) ✅ -- PG + S3 + CephFS (shared infrastructure) ✅ -- `pg_broadcast` LISTEN/NOTIFY (cross-pod event bus) ✅ - -**Delivered (5 changesets):** -- [x] Task scheduler: `FOR UPDATE SKIP LOCKED` atomic claim replaces - `ListDue`. `CreateRunExclusive` conditional insert for - belt-and-suspenders uniqueness. Startup jitter (0–15s). -- [x] WebSocket cross-pod delivery: `PublishToUser` routes through - bus → `pg_broadcast` → remote pod. 18 `SendToUser` call sites - migrated. `tool.result.*` routing changed to `DirBoth` for - cross-pod `WaitFor`. `TargetUserID` field on `Event`. -- [x] Shared ticket store: `ws_tickets` PG table with 30s TTL, - atomic `DELETE ... RETURNING` validation. Replaces `sync.Map`. -- [x] Shared rate limiter: `rate_limit_counters` PG table with - fixed-window counters. Fail-open policy on DB errors. -- [x] Health probes: `/healthz/ready` (PG ping, 2s timeout), - `/healthz/live` (process alive). Helm: `replicaCount: 2`, - pod anti-affinity, readiness/liveness probes. - -**Schema:** `020_ha.sql` — `ws_tickets`, `rate_limit_counters`. - -### v0.33.0 — Observability ✅ - -Metrics, dashboards, alerting. Operate the platform without reading -Go source code. - -Depends on: v0.32.0 (multi-replica metrics aggregation). - -**Delivered (6 changesets):** -- [x] Structured logging (`slog`): `LOG_FORMAT=json|text`, - `LOG_LEVEL=debug|info|warn|error`. Request ID middleware - (`X-Request-Id`), correlation IDs through completion chain. -- [x] Prometheus `/metrics` endpoint: HTTP request counters/histograms, - WebSocket gauge, completion tokens/duration, provider status, - DB pool stats, task/sandbox execution counters. -- [x] OpenAPI 3.0.3 spec (hand-curated, core API groups) + Swagger UI - at `/api/docs` with system dark/light mode, WCAG AA contrast. -- [x] Grafana dashboard JSON: request rate, latency percentiles, - provider health, token usage, DB pool, Go runtime. -- [x] PrometheusRule alerting: OOM recovery, provider down, pool - exhaustion, high error rate, task failure spike. -- [x] Admin monitoring dashboard: provider health, 24h usage, DB pool, - WS connections, Go runtime stats, storage status, recent errors, - 30s auto-refresh. - -**Deferred to v0.36.0 (delivered):** -- Full OpenAPI spec coverage (all 20 ICD domains) -- Bearer token / mTLS auth documentation in spec - -### v0.34.0 — Data Portability ✅ - -Export, import, backup, compliance. - -Depends on: v0.29.2 (DB extensions — extension data in exports). - -- [x] Bulk export/import: account data, conversations, settings, files -- [x] GDPR "download my data" + "delete my data" (cascade + audit trail) -- [x] ChatGPT/other tool import (conversation format mapping) -- [x] Backup/restore CronJob manifests for K8s -- [x] Admin export: team/user config (excludes vault-encrypted keys) - -### v0.35.0 — Workflow Product - -Bridge from workflow infrastructure to real business process automation. -Visitors see forms (not just chat), collected data flows through Starlark -enrichment, stages branch conditionally, and team members review -structured data — not chat transcripts. - -Depends on: v0.31.2 (team workflow self-service), v0.29.0 (Starlark sandbox). - -**Form Rendering Surface:** -- [x] Visitor form renderer — reads `form_template` from stage, renders - HTML inputs (text, email, select, date, textarea, checkbox, file), - validates client-side, submits via `/w/:id/form-submit` -- [x] Progressive form — multi-step within a single stage (fieldsets) -- [x] Conditional fields — show/hide based on previous answers -- [x] File upload in forms — attach to stage_data via storage API -- [x] Branded form page — uses workflow `branding` (colors, logo, tagline) - -**Data Pipeline (between-stage processing):** -- [x] `on_advance` hook — Starlark entry point fires after each stage - transition, receives `stage_data`, can enrich/transform/reject -- [x] External data enrichment — Starlark `http.fetch` pulls from - external APIs, merges results into `stage_data` (reduce double entry) -- [x] Data validation rules — Starlark `on_validate` can enforce - cross-field business rules beyond per-field type checks -- [x] Stage data schema — typed `stage_data` with declared fields, - not opaque JSON blob. Enables structured review views - -**Conditional Routing:** -- [x] Branch expressions — `transition_rules.condition` evaluated against - `stage_data` to select next stage (not always ordinal+1) -- [x] AI-triggered routing — persona calls `workflow_route` tool with - a target stage name based on conversation analysis -- [x] Escalation pattern — "if AI confidence < threshold, route to - human review stage" (help desk use case) -- [x] Loop stages — return to a previous stage for correction without - using reject (iterative data gathering) - -**Structured Review:** -- [x] Assignment review view — team member sees `stage_data` as a - structured card/form, not just chat history -- [x] Approval/reject with comments — reviewer adds notes visible - to the next stage (not buried in chat) -- [x] Side-by-side view — chat history + structured data together -- [x] Bulk review — multiple assignments in a queue with keyboard nav - -**Monitoring Dashboard:** -- [x] Active instances list — workflow name, current stage, assignee, - age, last activity -- [x] Stage funnel — how many instances at each stage, bottleneck detection -- [x] SLA timers — configurable per-stage, visible in review + dashboard -- [x] Stale instance alerts — notify team admins when instances age out - -**Use case validation targets:** -- Help desk: AI + KB → escalation to human → resolution tracking -- Data intake: form → Starlark enrichment → human review → follow-up -- Onboarding: multi-stage form → conditional branching → team assignment - -### 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 -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):** -- [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:** -- [x] Bearer JWT flow (login → token → `Authorization: Bearer `) -- [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:** -- [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) +## Completed Versions (see CHANGELOG.md for details) + +| Version | Summary | +|---------|---------| +| v0.28.0–v0.28.8 | Platform polish, ICD green board, security, infrastructure | +| v0.29.0–v0.29.3 | Extension track: Starlark sandbox, API extensions, DB extensions, workflow forms | +| v0.30.0–v0.30.2 | Package lifecycle, SDK adoption, workflow packages | +| v0.31.0–v0.31.2 | Editor package, SDK composability, team workflow self-service | +| v0.32.0 | Multi-replica HA (PG SKIP LOCKED, cross-pod WS, shared tickets/rate limits) | +| v0.33.0 | Observability (slog, Prometheus, Grafana, OpenAPI, admin dashboard) | +| v0.34.0 | Data portability (export/import, GDPR, backup/restore) | +| v0.35.0 | Workflow product (forms, data pipeline, conditional routing, structured review) | +| v0.36.0 | Full OpenAPI spec (20 ICD domains, auth docs, WebSocket schemas) | --- @@ -638,109 +182,57 @@ Each surface rebuilt as Preact component tree. Old JS deleted per surface. | v0.37.11 | Notes surface ✅ | Preact surface (7 JS + 1 CSS), sidebar folders/tags, UserMenu, template rewrite, Menu primitive fix | | v0.37.12 | Scorched Earth II ✅ | 23 files deleted (~2,600 lines): 9 JS, 6 CSS, 8 admin templates; sw.js + base.html cleaned | | v0.37.13 | Scorched Earth III ✅ | 5 JS deleted (−2,269 lines): ui-primitives, code-editor, file-tree, user-menu, app-state; SDK slimmed, Theme→Preact | -| v0.37.14 | Pane audit | Collaborative walkthrough: core features + theming checked | +| v0.37.14 | SE IV + Pane audit ✅ | 4 JS deleted (~−1,672 lines); double-unwrap cleanup (93 sites); API envelope normalization (18 Go handlers); thinking tags persistence; deleteMessage endpoint; extension surface user menu fix; 7 bug fixes | | v0.37.15 | Workflow surfaces | Form renderer, review views (design-first) | | v0.37.16 | Projects surface | Project views, note/channel associations (design-first) | -| v0.37.17 | Debug / model surface | Debug tooling, model configuration UI | -| v0.37.18 | Tag | Light mode CSS audit, dead code hunt, all features verified | +| v0.37.17 | Debug / model surface | Debug tooling, model configuration UI; debug modal Preact rebuild (CR P2-5) | +| v0.37.18 | Tag | Light mode CSS audit, dead code hunt, all features verified; `sw.can()` RBAC gates across surfaces (CR P2-1), `__USER__`/`__PAGE_DATA__` removal (CR P2-4), `_getScale` → `sw.shell.getScale()` (CR P3-3) | -**v0.37.11 — Notes Surface ✅:** +**v0.37.14 — Scorched Earth IV + Pane Audit ✅** (10 sessions, v0.37.14.0–.22): -Notes surface layout (composed kit into full-page Preact surface): -- [x] Sidebar with folder tree + tag browser (sidebar-folders.js, sidebar-tags.js) -- [x] Main content area with NotesPane standalone=false -- [x] Mobile-responsive layout (768px breakpoint, overlay sidebar) -- [x] UserMenu in sidebar footer (same as chat surface) -- [x] Template rewrite — crash catcher + single mount point pattern -- [x] Menu primitive CSS fix — `sw-primitives.css` added to base.html (cross-surface) -- [x] Avatar initials fix — single-word names show first letter only (cross-surface) -- [x] Old `.surface-notes*` CSS deleted from surfaces.css +Scorched Earth IV (4 JS deleted, ~−1,672 lines), unified sidebar with nested +folders + DnD, notification bell, @mentions, typing indicators, member panel, +double-unwrap cleanup (93 sites), API envelope normalization (18 Go handlers), +thinking tags persistence, deleteMessage endpoint, 6 bug fixes. +Cumulative scorched earth: 53 files, ~−19,382 lines. -NotesPane kit enhancements (deferred to v0.37.14 pane audit): -- [ ] Markdown toolbar — bold, italic, heading, link, code, list, checkbox -- [ ] Hover preview on wikilinks — floating card on `[[Title]]` hover -- [ ] Breadcrumb trail — navigation history through wikilinks -- [ ] Split view (editor + preview) — side-by-side live preview -- [ ] Note templates — "New from template" in toolbar -- [ ] Slash commands in editor — `/table`, `/code`, `/heading`, `/date`, `/link` -- [ ] Graph minimap — small overview with draggable viewport -- [ ] Resizable split panes +**v0.37.15 — Workflow Ownership & Lifecycle:** -Polish (deferred to v0.37.18 tag): -- [ ] Light mode CSS variable audit for `sw-notes-pane.css` -- [ ] Smooth view transitions (fade/slide between list → reader → editor) -- [ ] Note word count goal (optional target with progress bar) -- [ ] Server-side favorites via note metadata (cross-device pinning) +Team-admin workflow management as first-class path. See +[DESIGN-0.37.15](Workflow/DESIGN-0.37.15.md) for full design. +Instance cancel/unclaim/reassign, stage CRUD FE wiring, team-admin +workflow queue + stage editor + instance monitor surfaces. -**v0.37.12 — Scorched Earth II ✅:** - -Second dead-code purge. All six core surfaces confirmed Preact-only — old -vanilla JS, CSS, and admin templates that survived v0.37.10 are now removed. -Service worker cache manifest overhauled (17 stale entries → clean). - -Deleted (23 files): -- [x] 9 JS: `ui-format.js`, `virtual-scroll.js`, `model-selector.js`, - `drag-resize.js`, `pages-splash.js`, `task-sidebar.js`, - `workflow-api.js`, `workflow-queue.js`, `workflow-monitor.js` -- [x] 6 CSS: `splash.css`, `styles.css`, `persona-kb.css`, - `notification-prefs.css`, `memory.css`, `channel-models.css` -- [x] 8 admin templates: `server/pages/templates/admin/` directory -- [x] 7 script/link tags removed from `base.html` (includes - `marked.min.js` + `purify.min.js` — surfaces load their own copies) -- [x] `sw.js` SHELL_FILES array replaced with actual file inventory -- [x] `pages.go` admin template embed directive removed - -Kept (11 JS files — extension surface + debug dependencies): -`sb.js`, `app-state.js`, `events.js`, `ui-primitives.js`, `user-menu.js`, -`file-tree.js`, `code-editor.js`, `switchboard-sdk.js`, `debug.js`, -`repl.js`, `workflow-surfaces.js` - -**v0.37.13 — Scorched Earth III ✅:** - -Third dead-code purge. 5 files deleted (−2,269 net lines). The 1,133-line -`ui-primitives.js` is gone; Theme management migrated to Preact SDK module. -SDK slimmed by removing 6 dead factory wrappers. - -Deleted (5 files): -- [x] `ui-primitives.js` (1,133 LOC) — `esc`, component lifecycle, Providers, - Roles, modal/confirm/prompt, Theme, render helpers -- [x] `code-editor.js` (362 LOC) — tabbed CodeMirror 6 editor -- [x] `file-tree.js` (290 LOC) — workspace file browser -- [x] `user-menu.js` (206 LOC) — vanilla flyout menu -- [x] `app-state.js` (139 LOC) — global `window.App` state -- [x] 5 script tags removed from `base.html` -- [x] 5 entries removed from `sw.js` SHELL_FILES -- [x] `switchboard-sdk.js` slimmed: 6 dead wrappers removed, Theme→Preact SDK - -Kept (6 JS files — extension + debug dependencies): -`sb.js`, `events.js`, `switchboard-sdk.js`, `debug.js`, `repl.js`, -`workflow-surfaces.js` - -Cumulative scorched earth: 49 files, ~−17,710 lines (v0.37.10 + v0.37.12 + v0.37.13) - -**v0.37.14 — Pane Audit:** - -Collaborative walkthrough of core features and theming. Each pane and surface -checked for form, function, and visual consistency. Fixes applied in-place. -No new architecture — making everything work correctly. - -- [ ] ChatPane: message rendering, streaming, model selector, markdown, code blocks -- [ ] NotesPane: list, editor, reader, graph, quick switcher, save-to-note -- [ ] Cross-pane: toast, dialog, user menu, keyboard shortcuts -- [ ] Theming: light/dark mode consistency audit across all surfaces -- [ ] Kit enhancement backlog triage (above items evaluated for inclusion) - -**v0.37.15 — Projects Surface:** +**v0.37.16 — Projects Surface:** Project views, note/channel associations, project-scoped navigation. -**v0.37.16 — Workflow Assignment:** +**v0.37.17 — Debug + Model Surface:** -Workflow routing and assignment UX. +Debug modal Preact rebuild, model configuration UI, provider diagnostics. -**v0.37.17 — Debug / Model Surface:** +**v0.37.18 — Tag ("UI Complete"):** -Debug tooling, model configuration UI, provider diagnostics. +`sw.can()` RBAC gates, `__USER__`/`__PAGE_DATA__` removal, `_getScale` SDK, +light mode CSS audit, dead code hunt. Every capability has a UI. + +--- + +## v0.38.x — Workflow Product Maturity + +See [ROADMAP-0.38](Workflow/ROADMAP-0.38.md) for full version plan. + +Visual workflow builder series. Bridges .37 plumbing to MVP gate +("team admins build workflows visually"). + +| Version | Summary | MVP Role | +|---------|---------|----------| +| v0.38.0 | Form Builder | **Blocker** — visual field editor replaces JSON textarea | +| v0.38.1 | Stage Reorder + Bulk Ops | DnD reorder, multi-select operations | +| v0.38.2 | SLA Alerting | Scheduled scanner, breach notifications | +| v0.38.3 | Visitor Experience | Branding, progress indicator, email notifications | +| v0.38.4 | Workflow Templates + Clone | 5 built-in templates, deep copy | +| v0.38.5 | Analytics Dashboard | Throughput, cycle time, SLA compliance | --- diff --git a/docs/Workflow/DESIGN-0.37.15.md b/docs/Workflow/DESIGN-0.37.15.md new file mode 100644 index 0000000..f171954 --- /dev/null +++ b/docs/Workflow/DESIGN-0.37.15.md @@ -0,0 +1,715 @@ +# DESIGN-0.37.15 — Workflow Ownership & Lifecycle + +Fixes the two structural problems in the workflow system: (1) workflows +are admin-only with no way to clean up stale instances, and (2) the FE +surfaces don't expose the team-admin capabilities the BE already has. + +90%+ of workflows are team-admin generated. Assignments happen to team +members on their stage turn. This design makes that the first-class path. + +Depends on: v0.37.14 (FE Rewrite), v0.35.0 (Workflow Product). + +--- + +## Use Cases + +### UC1 — Pure Data Gather (no LLM, no chat) + +**Example:** Employee onboarding form. HR team admin creates a 3-stage +workflow: (1) employee fills a form, (2) HR reviews and approves, +(3) IT provisions accounts. + +- Stage 0: `form_only`, `entry_mode: public_link` — visitor sees a form +- Stage 1: `review`, `assignment_team_id: hr-team` — HR member claims, reviews data, advances +- Stage 2: `form_only`, `assignment_team_id: it-team` — IT fills provisioning fields, completes + +**No LLM involved at any stage.** No chat pane. The visitor never sees +stages 1-2. They get a terminal "submitted" screen after stage 0. + +### UC2 — Data Gather + Live Chat with Team Member (no LLM) + +**Example:** Customer support intake. Customer fills a form describing +their issue, then gets a live chat channel with a support agent. + +- Stage 0: `form_only`, `entry_mode: public_link` — customer fills issue form +- Stage 1: `form_chat`, `assignment_team_id: support-team` — agent claims, chats with customer +- Stage 2: `review`, `assignment_team_id: support-leads` — lead reviews transcript, closes + +**No LLM.** The chat is human-to-human. The customer sees stages 0-1 +(their form and the chat). Stage 2 is internal. + +### UC3 — LLM-Driven Intake Triggering Team Member + +**Example:** IT help desk. An AI persona does first-level triage via +chat, gathers structured data, then routes to the right team. + +- Stage 0: `chat_only`, `persona_id: helpdesk-bot`, `entry_mode: public_link` — LLM chats with user, extracts issue type +- Stage 1: `review`, `assignment_team_id` resolved by routing rules — team member reviews AI summary + transcript +- Stage 2: `form_chat`, same team — agent works with user if needed, fills resolution form + +**LLM is stage 0 only.** Stages 1-2 are human. Conditional routing +(`transition_rules.conditions`) determines which team gets stage 1 based +on data the LLM extracted. + +### Cross-Cutting: Stage Visibility + +Stages have an audience: + +| Audience | Who sees it | Examples | +|----------|-------------|---------| +| `visitor` | The person who entered the workflow | Stage 0 form, stage 1 chat with agent | +| `team` | Members of the assigned team only | Internal review, triage, approval | +| `system` | No human UI — automated transition | Webhook fire, data enrichment hook | + +**Rule:** Once a stage's audience is `team`, the visitor's view is +frozen. They see a "your request is being processed" terminal. They +never see the internal stages, the assignment queue, or downstream +team handoffs. + +This is NOT a new DB column. It's implied by the stage configuration: +- `form_only` or `chat_only` with no `assignment_team_id` → visitor-facing +- Any stage with `assignment_team_id` → team-facing +- `auto_transition: true` with no persona and no form → system + +The FE reads the stage config and renders accordingly. + +### Cross-Cutting: Multi-Team Handoffs + +Stage 1 is Team A (triage). Stage 2 is Team B (specialist). Stage 3 +is Team A again (close-out). Each team sees ONLY their assignments. +`ListAssignmentsForTeam` already filters by `team_id`. The FE must +scope the queue view to the selected team context. + +### Cross-Cutting: Instance Lifecycle + +Current status enum: `active | completed`. +Add: `cancelled`. + +Who can cancel: +- The instance owner (the user who started it) +- A team admin of the owning team +- A global admin + +Cancelling an instance sets `workflow_status = 'cancelled'` and +cancels all open assignments (`status IN ('unassigned','claimed')`). + +### Cross-Cutting: Assignment Lifecycle + +Current status enum: `unassigned | claimed | completed`. +Add: `cancelled`. + +New operations: +- **Unclaim** — returns `claimed` → `unassigned` (claimer or team admin) +- **Reassign** — changes `assigned_to` on a claimed assignment (team admin) +- **Cancel** — sets `cancelled` (team admin, fires when instance is cancelled) + +--- + +## JSX Exemplars + +These are specification-grade components. Implementation translates to +the project's `htm` tagged template syntax. Props match the API response +shapes. State management uses the SDK (`sw.api.*`). + +### E1 — Team Workflow Queue (the "inbox") + +This is the primary surface a team member sees. It replaces the current +bare list of workflows with an actionable assignment queue. + +```jsx +/** + * TeamWorkflowQueue — assignment inbox for a team member + * + * Shows: my claimed assignments, then unassigned assignments I can claim. + * Actions: claim, unclaim, open (navigate to workflow channel), complete. + * + * Mount: team-admin surface, "Assignments" tab + * Data: GET /api/v1/teams/:teamId/assignments?status=unassigned + * GET /api/v1/teams/:teamId/assignments?status=claimed + */ +function TeamWorkflowQueue({ teamId }) { + const [claimed, setClaimed] = useState([]); + const [unassigned, setUnassigned] = useState([]); + const [loading, setLoading] = useState(true); + + async function load() { + const [c, u] = await Promise.all([ + sw.api.teams.assignments(teamId, { status: 'claimed' }), + sw.api.teams.assignments(teamId, { status: 'unassigned' }), + ]); + setClaimed(c || []); + setUnassigned(u || []); + setLoading(false); + } + + useEffect(() => { load(); }, [teamId]); + + // WS live update: listen for workflow.assigned / workflow.claimed + useEffect(() => { + const off1 = sw.on('workflow.assigned', load); + const off2 = sw.on('workflow.claimed', load); + return () => { off1(); off2(); }; + }, [teamId]); + + async function claim(id) { + await sw.api.workflowAssignments.claim(id); + load(); + } + + async function unclaim(id) { + await sw.api.workflowAssignments.unclaim(id); + load(); + } + + return ( +
+ {/* ── My Claimed ── */} +

My Active ({claimed.length})

+ {claimed.map(a => ( +
+
+ {a.workflow_name || 'Workflow'} + {a.stage_name || `Stage ${a.stage}`} + {a.sla_breached && SLA} +
+
+ + +
+
+ ))} + + {/* ── Unassigned ── */} +

Available ({unassigned.length})

+ {unassigned.map(a => ( +
+
+ {a.workflow_name || 'Workflow'} + {a.stage_name || `Stage ${a.stage}`} + {timeAgo(a.created_at)} +
+
+ +
+
+ ))} + + {claimed.length === 0 && unassigned.length === 0 && ( +
No assignments — queue is clear
+ )} +
+ ); +} +``` + +### E2 — Stage Editor (team-admin inline) + +The current team-admin workflows surface shows stages read-only. This +replaces it with a full inline editor. + +```jsx +/** + * StageEditor — inline stage list with add/edit/delete/reorder + * + * Mount: team-admin workflows surface, inside the workflow edit view. + * Data: GET /api/v1/teams/:teamId/workflows/:wfId/stages + * POST /api/v1/teams/:teamId/workflows/:wfId/stages + * PUT /api/v1/teams/:teamId/workflows/:wfId/stages/:sid + * DELETE /api/v1/teams/:teamId/workflows/:wfId/stages/:sid + * PATCH /api/v1/teams/:teamId/workflows/:wfId/stages/reorder + */ +function StageEditor({ teamId, workflowId }) { + const [stages, setStages] = useState([]); + const [editing, setEditing] = useState(null); // stage id or 'new' + const [teams, setTeams] = useState([]); + const [personas, setPersonas] = useState([]); + + async function load() { + const [s, t, p] = await Promise.all([ + sw.api.teams.workflowStages(teamId, workflowId), + sw.api.teams.members(teamId), // for assignment_team picker + sw.api.teams.personas(teamId), // for persona picker + ]); + setStages(s || []); + setTeams(t || []); + setPersonas(p || []); + } + + useEffect(() => { load(); }, [workflowId]); + + async function addStage(data) { + await sw.api.teams.createWorkflowStage(teamId, workflowId, data); + setEditing(null); + load(); + } + + async function updateStage(stageId, data) { + await sw.api.teams.updateWorkflowStage(teamId, workflowId, stageId, data); + setEditing(null); + load(); + } + + async function deleteStage(stageId) { + const ok = await sw.confirm('Delete this stage?', true); + if (!ok) return; + await sw.api.teams.deleteWorkflowStage(teamId, workflowId, stageId); + load(); + } + + return ( +
+
+
Stages ({stages.length})
+ +
+ + {/* ── Stage list (drag handle placeholder for reorder) ── */} + {stages.map((s, i) => ( +
+ #{i + 1} +
+ {s.name} + {s.stage_mode} + {s.persona_id && persona} + {s.assignment_team_id && team assign} +
+
+ + +
+
+ ))} + + {/* ── Inline edit/create form ── */} + {editing && ( + s.id === editing)} + personas={personas} + teams={teams} + onSave={(data) => editing === 'new' + ? addStage(data) + : updateStage(editing, data) + } + onCancel={() => setEditing(null)} + /> + )} +
+ ); +} + +/** + * StageForm — create/edit a single stage + * + * Fields: name, stage_mode, persona_id, assignment_team_id, + * history_mode, auto_transition, sla_seconds. + * Form template editing is a separate concern (CS6+). + */ +function StageForm({ stage, personas, teams, onSave, onCancel }) { + const [name, setName] = useState(stage?.name || ''); + const [mode, setMode] = useState(stage?.stage_mode || 'chat_only'); + const [personaId, setPersonaId] = useState(stage?.persona_id || ''); + const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || ''); + const [historyMode, setHistoryMode] = useState(stage?.history_mode || 'full'); + const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false); + const [sla, setSla] = useState(stage?.sla_seconds || ''); + + function submit() { + onSave({ + name, + stage_mode: mode, + persona_id: personaId || null, + assignment_team_id: assignTeam || null, + history_mode: historyMode, + auto_transition: autoTransition, + sla_seconds: sla ? parseInt(sla, 10) : null, + }); + } + + return ( +
+
+
+ + setName(e.target.value)} /> +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + setSla(e.target.value)} placeholder="e.g. 3600" /> +
+
+ +
+ + +
+
+ ); +} +``` + +### E3 — Instance Monitor (team-admin) + +Shows active instances for the team's workflows with cancel capability. + +```jsx +/** + * TeamInstanceMonitor — active workflow instances for a team + * + * Mount: team-admin workflows surface, "Monitor" tab + * Data: GET /api/v1/teams/:teamId/workflows/monitor/instances + * Actions: cancel instance + */ +function TeamInstanceMonitor({ teamId }) { + const [instances, setInstances] = useState([]); + const [loading, setLoading] = useState(true); + + async function load() { + const data = await sw.api.teams.workflowInstances(teamId); + setInstances(data || []); + setLoading(false); + } + + useEffect(() => { load(); }, [teamId]); + + async function cancelInstance(channelId) { + const ok = await sw.confirm( + 'Cancel this workflow instance? All open assignments will be cancelled.', true + ); + if (!ok) return; + await sw.api.teams.cancelWorkflowInstance(teamId, channelId); + sw.toast('Instance cancelled', 'success'); + load(); + } + + return ( +
+

Active Instances ({instances.length})

+ {instances.map(inst => ( +
+
+ {inst.workflow_name} + {inst.stage_name || `Stage ${inst.current_stage}`} + {inst.sla_breached && SLA breached} + Age: {formatDuration(inst.age_seconds)} +
+
+ + +
+
+ ))} + {instances.length === 0 &&
No active instances
} +
+ ); +} +``` + +### E4 — My Assignments (user settings) + +Every authenticated user sees their personal assignment queue across +all teams they belong to. This is the "what's on my plate" view. + +```jsx +/** + * MyAssignments — user's personal assignment queue + * + * Mount: settings surface, "Assignments" section + * Data: GET /api/v1/workflow-assignments/mine + * Actions: claim, unclaim, open channel, complete + */ +function MyAssignments() { + const [assignments, setAssignments] = useState([]); + const [loading, setLoading] = useState(true); + + async function load() { + const data = await sw.api.workflowAssignments.mine(); + setAssignments(data || []); + setLoading(false); + } + + useEffect(() => { load(); }, []); + + // Group: my claimed first, then available + const mine = assignments.filter(a => a.status === 'claimed' && a.assigned_to === sw.auth.user?.id); + const available = assignments.filter(a => a.status === 'unassigned'); + + return ( +
+ {mine.length > 0 && ( + <> +

Claimed ({mine.length})

+ {mine.map(a => ( + + ))} + + )} + + {available.length > 0 && ( + <> +

Available ({available.length})

+ {available.map(a => ( + + ))} + + )} + + {mine.length === 0 && available.length === 0 && ( +
No assignments
+ )} +
+ ); +} + +/** + * AssignmentRow — reusable row for any assignment context + * + * Shared between TeamWorkflowQueue (E1) and MyAssignments (E4). + * The showTeam prop controls whether the team badge is visible + * (needed in MyAssignments where assignments span multiple teams). + */ +function AssignmentRow({ assignment: a, onAction, showTeam }) { + const isMine = a.assigned_to === sw.auth.user?.id; + + async function claim() { + await sw.api.workflowAssignments.claim(a.id); + onAction(); + } + + async function unclaim() { + await sw.api.workflowAssignments.unclaim(a.id); + onAction(); + } + + async function complete() { + await sw.api.workflowAssignments.complete(a.id); + sw.toast('Assignment completed', 'success'); + onAction(); + } + + return ( +
+
+ {a.workflow_name || 'Workflow'} + {showTeam && a.team_name && {a.team_name}} + {a.stage_name || `Stage ${a.stage}`} + {a.sla_breached && SLA} + {timeAgo(a.created_at)} +
+
+ {a.status === 'unassigned' && ( + + )} + {isMine && ( + <> + + + + + )} +
+
+ ); +} +``` + +### E5 — Visitor Terminal Screen + +When a visitor completes their last visible stage, they see this +instead of the internal team stages. + +```jsx +/** + * VisitorTerminal — "thank you" screen after visitor stages complete + * + * The workflow surface (Go template + JS) detects when the current + * stage is team-facing and the visitor is not a team member. + * Instead of rendering the stage, it renders this terminal. + * + * Mount: workflow surface (replaces stage content) + * Data: channel workflow_status + stage config + */ +function VisitorTerminal({ workflowName, branding, status }) { + // status: 'active' (still being processed) | 'completed' | 'cancelled' + const messages = { + active: { title: 'Request Submitted', body: 'Your request is being reviewed. You\'ll be contacted if we need more information.' }, + completed: { title: 'Complete', body: 'Your request has been processed. Thank you.' }, + cancelled: { title: 'Cancelled', body: 'This request has been cancelled.' }, + }; + + const msg = messages[status] || messages.active; + + return ( +
+ {branding?.logo_url && ( + + )} +

{msg.title}

+

{msg.body}

+ {branding?.tagline && ( +

{branding.tagline}

+ )} +
+ ); +} +``` + +--- + +## BE Additions + +### New Store Methods + +```go +// ── ChannelStore additions ── + +// CancelWorkflow sets workflow_status = 'cancelled' on a workflow channel. +CancelWorkflow(ctx context.Context, channelID string) error + +// ── WorkflowStore additions ── + +// CancelAssignmentsForChannel sets status = 'cancelled' on all +// unassigned/claimed assignments for a channel. +CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error) + +// UnclaimAssignment returns a claimed assignment to unassigned. +// Returns rows affected (0 = not claimed or not found). +UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error) + +// ReassignAssignment changes assigned_to on a claimed assignment. +// Returns rows affected. +ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error) + +// CancelAssignment sets a single assignment to cancelled. +CancelAssignment(ctx context.Context, assignmentID string) (int64, error) +``` + +### New Handler Endpoints + +``` +POST /api/v1/channels/:id/workflow/cancel — owner or team admin +POST /api/v1/workflow-assignments/:id/unclaim — claimer or team admin +POST /api/v1/workflow-assignments/:id/reassign — team admin +POST /api/v1/workflow-assignments/:id/cancel — team admin + +# Team-scoped variants (for team-admin surface): +POST /api/v1/teams/:teamId/workflows/monitor/instances/:channelId/cancel +``` + +### New FE API Domains + +```js +// teams domain additions: +assignments: (id, opts) => rc.get(`/api/v1/teams/${id}/assignments` + _qs(opts)), +cancelWorkflowInstance: (id, chId) => rc.post(`/api/v1/teams/${id}/workflows/monitor/instances/${chId}/cancel`, {}), +workflowInstances: (id) => rc.get(`/api/v1/teams/${id}/workflows/monitor/instances`), +// stage CRUD (routes exist, FE bindings missing): +workflowStages: (id, wfId) => rc.get(`/api/v1/teams/${id}/workflows/${wfId}/stages`), +createWorkflowStage: (id, wfId, data) => rc.post(`/api/v1/teams/${id}/workflows/${wfId}/stages`, data), +updateWorkflowStage: (id, wfId, sid, data) => rc.put(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`, data), +deleteWorkflowStage: (id, wfId, sid) => rc.del(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`), +reorderWorkflowStages: (id, wfId, ids) => rc.patch(`/api/v1/teams/${id}/workflows/${wfId}/stages/reorder`, { ordered_ids: ids }), + +// workflowAssignments domain (new top-level): +workflowAssignments: { + mine: () => rc.get('/api/v1/workflow-assignments/mine'), + get: (id) => rc.get(`/api/v1/workflow-assignments/${id}`), + claim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/claim`, {}), + unclaim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/unclaim`, {}), + complete: (id) => rc.post(`/api/v1/workflow-assignments/${id}/complete`, {}), + reassign: (id, userId) => rc.post(`/api/v1/workflow-assignments/${id}/reassign`, { user_id: userId }), + cancel: (id) => rc.post(`/api/v1/workflow-assignments/${id}/cancel`, {}), + comment: (id, text) => rc.post(`/api/v1/workflow-assignments/${id}/comment`, { text }), +}, +``` + +### Schema Notes + +No new migration files needed IF `workflow_status` and assignment +`status` are TEXT columns (they are). The new `cancelled` value is +just a string. Verify with: + +```sql +-- Should return TEXT, not an enum: +SELECT column_name, data_type FROM information_schema.columns +WHERE table_name = 'channels' AND column_name = 'workflow_status'; +``` + +--- + +## Changeset Plan + +| CS | Scope | Files | Notes | +|----|-------|-------|-------| +| CS1 | BE: instance cancel | store iface + pg/sqlite impl + handler + routes | CancelWorkflow, CancelAssignmentsForChannel | +| CS2 | BE: assignment lifecycle | store iface + pg/sqlite impl + handler + routes | Unclaim, Reassign, CancelAssignment | +| CS3 | FE: api-domains wiring | `api-domains.js` | Wire all missing team stage CRUD + assignment operations | +| CS4 | FE: team-admin workflows rewrite | `team-admin/workflows.js` (split to sub-files if needed) | Stage editor (E2), instance monitor (E3), queue (E1) | +| CS5 | FE: settings assignments | `settings/workflows.js` → rename to `settings/assignments.js` | MyAssignments (E4), replaces read-only workflow list | + +CS1-CS2 are BE-only, testable via integration tests. CS3 is a single +file, no visual changes. CS4-CS5 are FE-only, depend on CS3. + +--- + +## What We're NOT Doing in .15 + +- Form template visual editor (stage form_template is still raw JSON) +- Workflow template sharing between teams +- Bulk operations (batch cancel, batch reassign) +- SLA alerting/notification automation (monitor is read-only) +- Visitor terminal customization beyond branding (E5 is static) +- Drag-and-drop stage reorder (button-based reorder only) +- Workflow analytics/reporting beyond the existing funnel endpoint diff --git a/docs/Workflow/ROADMAP-0.38.md b/docs/Workflow/ROADMAP-0.38.md new file mode 100644 index 0000000..226685c --- /dev/null +++ b/docs/Workflow/ROADMAP-0.38.md @@ -0,0 +1,584 @@ +# ROADMAP-0.38 — Workflow Product Maturity + +Everything deferred from v0.37.15 plus what the MVP gate actually +requires: "Team admins build workflows **visually**." + +v0.37.15 delivers the plumbing — cancel, unclaim, reassign, stage CRUD +in the FE, queue views. This series builds the product on top of that +plumbing. + +--- + +## Series Overview + +``` +v0.37.15 Workflow Ownership & Lifecycle (plumbing) +v0.37.16 Projects Surface +v0.37.17 Debug / Model Surface + │ + ── 0.37 tag ── + │ +v0.38.0 Form Builder ← team admins stop writing JSON +v0.38.1 Stage Reorder + Bulk Ops ← queue management at scale +v0.38.2 SLA Alerting ← stale queues self-report +v0.38.3 Visitor Experience ← the public face +v0.38.4 Workflow Templates + Clone ← reuse across teams +v0.38.5 Analytics Dashboard ← throughput, bottlenecks, SLA compliance + │ + ─ ─ ─ MVP v0.50.0 gate ─ ─ ─ +``` + +**Milestone gates:** + +| Milestone | Gate | Meaning | +|-----------|------|---------| +| v0.37 tag | **UI Complete** | Every platform capability has a surface; no features require raw API calls | +| v0.38.0 | **Self-service** | A team admin can build a complete workflow without touching JSON or asking a global admin | +| v0.38.2 | **Operational** | Stale work surfaces automatically; team admins can manage queues without monitoring dashboards | +| v0.38.5 | **Measurable** | Team leads can answer "how long does stage X take" and "where are we bottlenecked" | + +--- + +## v0.38.0 — Form Builder + +**The single biggest gap.** `form_template` is currently raw JSON in a +textarea. The MVP gate says "build workflows visually" — this is the +make-or-break. + +### Scope + +A visual form builder embedded in the stage editor (E2 from the .15 +design). Not a standalone surface — it's a panel that opens when you +click "Edit Form" on a `form_only` or `form_chat` stage. + +### Fields supported (matching existing TypedFormTemplate) + +`text`, `email`, `select`, `number`, `date`, `textarea`, `checkbox`, +`file`. Each with the validation rules already defined in +`models/workflow.go` (min/max length, pattern, min/max value, etc). + +### JSX Exemplar + +```jsx +/** + * FormBuilder — visual editor for stage form_template + * + * Opens as a slide-out panel from StageEditor. + * Produces a TypedFormTemplate JSON blob on save. + * + * Features: + * - Add/remove/reorder fields + * - Field type picker with type-specific validation config + * - Fieldset grouping (progressive multi-step forms) + * - Conditional visibility (when/op/value) + * - Live preview pane + */ +function FormBuilder({ initial, onSave, onCancel }) { + const [fields, setFields] = useState(initial?.fields || []); + const [fieldsets, setFieldsets] = useState(initial?.fieldsets || []); + const [useFieldsets, setUseFieldsets] = useState((initial?.fieldsets?.length || 0) > 0); + const [preview, setPreview] = useState(false); + + function addField(targetFieldset) { + const field = { + key: `field_${Date.now()}`, + type: 'text', + label: '', + required: false, + }; + if (useFieldsets && targetFieldset !== undefined) { + // Add to specific fieldset + setFieldsets(fs => fs.map((f, i) => + i === targetFieldset ? { ...f, fields: [...f.fields, field] } : f + )); + } else { + setFields(f => [...f, field]); + } + } + + function save() { + const template = useFieldsets + ? { fieldsets, hooks: initial?.hooks || null } + : { fields, hooks: initial?.hooks || null }; + onSave(template); + } + + return ( +
+
+

Form Builder

+ + +
+ + {preview ? ( + + ) : ( +
+ {useFieldsets ? ( + + ) : ( + + )} + +
+ )} + +
+ + +
+
+ ); +} + +/** + * FieldEditor — single field configuration row + * + * Inline editing of key, type, label, required, validation, condition. + * Type-specific validation options appear contextually. + */ +function FieldEditor({ field, onChange, onRemove, allFields }) { + function set(k, v) { onChange({ ...field, [k]: v }); } + + return ( +
+
+
+ + set('label', e.target.value)} /> +
+
+ + set('key', e.target.value)} + placeholder="field_name" /> +
+
+ + +
+
+ +
+ + + {/* Type-specific validation — only render what applies */} + {(field.type === 'text' || field.type === 'textarea') && ( + set('validation', v)} /> + )} + {field.type === 'number' && ( + set('validation', v)} /> + )} + {field.type === 'select' && ( + set('options', o)} /> + )} +
+ + {/* Conditional visibility */} + set('condition', c)} + allFields={allFields} /> + + +
+ ); +} +``` + +### BE changes + +None. The form builder is pure FE — it produces the same +`TypedFormTemplate` JSON that already exists. The BE validation +in `models/workflow.go` is unchanged. + +### Deliverables + +| File | What | +|------|------| +| `src/js/sw/components/form-builder/index.js` | FormBuilder root | +| `src/js/sw/components/form-builder/field-editor.js` | FieldEditor, type-specific validators | +| `src/js/sw/components/form-builder/fieldset-editor.js` | Fieldset grouping | +| `src/js/sw/components/form-builder/condition-editor.js` | Conditional visibility | +| `src/js/sw/components/form-builder/preview.js` | Live form preview | +| `src/css/form-builder.css` | Styles | +| Integration into StageEditor (E2 from .15) | "Edit Form" button opens builder | + +--- + +## v0.38.1 — Stage Reorder + Bulk Ops + +### Stage drag-and-drop + +Replace button-based reorder with drag-and-drop in the stage editor. +No external library — use the HTML5 Drag and Drop API with +`draggable`, `ondragstart`, `ondragover`, `ondrop`. + +The reorder PATCH endpoint already exists: +`PATCH /api/v1/teams/:teamId/workflows/:wfId/stages/reorder` + +### Bulk assignment operations + +Team admins managing 20+ assignments need batch actions. + +```jsx +/** + * BulkAssignmentBar — selection-based bulk actions + * + * Appears above the queue when 1+ assignments are selected. + * Actions: bulk cancel, bulk reassign (to specific user). + */ +function BulkAssignmentBar({ selected, teamId, onAction }) { + const [reassignTo, setReassignTo] = useState(''); + + async function bulkCancel() { + const ok = await sw.confirm(`Cancel ${selected.length} assignments?`, true); + if (!ok) return; + await Promise.all(selected.map(id => sw.api.workflowAssignments.cancel(id))); + sw.toast(`${selected.length} cancelled`, 'success'); + onAction(); + } + + async function bulkReassign() { + if (!reassignTo) return; + await Promise.all(selected.map(id => + sw.api.workflowAssignments.reassign(id, reassignTo) + )); + sw.toast(`${selected.length} reassigned`, 'success'); + onAction(); + } + + return ( +
+ {selected.length} selected + +
+ + +
+
+ ); +} +``` + +### BE changes + +None. Bulk ops are client-side `Promise.all` over existing single +endpoints. If performance becomes an issue at scale (50+ assignments), +add a `POST /api/v1/workflow-assignments/bulk` endpoint in a future +patch. YAGNI for now. + +--- + +## v0.38.2 — SLA Alerting + +The monitor already computes `sla_breached`. This version makes it +proactive instead of requiring someone to check the dashboard. + +### Scheduled SLA scanner + +A new periodic task (Go scheduler, not Starlark) that runs every N +minutes: + +1. Query active workflow channels with `sla_seconds` configured +2. Compute breach status from `stage_entered_at` +3. For newly breached instances (not yet notified): + - Create notification for the assigned team members + - Emit `workflow.sla_breached` WS event + - If webhook_url configured, fire webhook + +### Data changes + +Add `sla_notified_at` (nullable timestamp) to the channels table. +The scanner only fires notifications when `sla_notified_at IS NULL` +and the SLA is breached, then sets the timestamp. Prevents duplicate +alerts. + +### FE changes + +The queue items (E1, E4) already render `sla_breached` badges. Add: +- Pulsing animation on breached badges +- Toast on `workflow.sla_breached` WS event +- Notification bell integration (already wired in .15 via + `notifications.NotifyWorkflowClaimed` pattern) + +### Deliverables + +| File | What | +|------|------| +| `server/scheduler/sla_scanner.go` | Periodic SLA check | +| `server/notifications/workflow_sla.go` | SLA notification builder | +| Migration: `sla_notified_at` column | Both PG + SQLite | +| FE: badge animation CSS | `src/css/sw-primitives.css` | + +--- + +## v0.38.3 — Visitor Experience + +The visitor currently gets a raw workflow surface. After .15 they get +a terminal screen (E5) when their stages are done. This version makes +the visitor experience feel like a product. + +### Scope + +1. **Branded entry page** — the workflow landing page already supports + branding (`accent_color`, `logo_url`, `tagline`). Extend with: + - Custom welcome text (markdown) + - Background image/gradient + - "Powered by" toggle (hide/show Switchboard branding) + +2. **Progress indicator** — visitor sees "Step 1 of N" for their + visible stages only. Internal team stages are invisible. The + progress bar counts only stages where the visitor is the audience. + +3. **Email notifications** — optional. When configured on the workflow: + - Collect email at stage 0 (or extract from form data) + - Send "request received" confirmation + - Send "your request is complete" when workflow completes + - No notifications for internal stage transitions + +4. **Session resume** — visitor who closes the browser can return to + `/w/:id/:slug` and resume where they left off. Already partially + implemented via `sb_session` cookie. Polish the UX: show "Welcome + back, pick up where you left off" instead of starting fresh. + +### BE changes + +```go +// Workflow model additions: +type WorkflowBranding struct { + AccentColor string `json:"accent_color"` + LogoURL string `json:"logo_url"` + Tagline string `json:"tagline"` + WelcomeText string `json:"welcome_text"` // NEW: markdown + BackgroundCSS string `json:"background_css"` // NEW: gradient or image URL + HidePoweredBy bool `json:"hide_powered_by"` // NEW +} + +// Email notification config (in workflow settings): +type WorkflowEmailConfig struct { + Enabled bool `json:"enabled"` + FromName string `json:"from_name"` + OnSubmit string `json:"on_submit_template"` // markdown template + OnComplete string `json:"on_complete_template"` // markdown template +} +``` + +Email sending requires an SMTP config at the platform level +(admin settings). If not configured, email toggle is disabled in +the workflow editor with a hint. + +### JSX Exemplar + +```jsx +/** + * VisitorProgress — step indicator for visitor-facing stages + * + * Mount: workflow surface, above the stage content + * Only counts stages where audience = visitor. + */ +function VisitorProgress({ stages, currentStage }) { + // Filter to visitor-facing stages only + const visitorStages = stages.filter(s => !s.assignment_team_id); + const visitorIndex = visitorStages.findIndex(s => s.ordinal === currentStage); + const total = visitorStages.length; + + if (total <= 1) return null; // Single stage = no progress bar + + return ( +
+ {visitorStages.map((s, i) => ( +
+
{i < visitorIndex ? '✓' : i + 1}
+ {s.name} +
+ ))} +
+ ); +} +``` + +--- + +## v0.38.4 — Workflow Templates + Clone + +Team admins shouldn't rebuild common patterns from scratch. Two +features: + +### Clone workflow + +"Duplicate" button on the workflow editor. Creates a new workflow with +the same stages, form templates, and routing rules. New slug, new name +(appended "- Copy"). No versions carried over (must re-publish). + +``` +POST /api/v1/teams/:teamId/workflows/:id/clone +→ 201 { ...newWorkflow } +``` + +Pure BE operation — deep-copies workflow + stages, generates new IDs. + +### Built-in templates + +Seed a `workflow_templates` table with 3-5 common patterns: + +| Template | Stages | Description | +|----------|--------|-------------| +| Customer Intake | form → review | Public form, team reviews | +| IT Help Desk | LLM chat → triage → resolution | AI-assisted intake | +| Approval Chain | form → approve → approve → notify | Multi-level approval | +| Feedback Survey | form (multi-step) → analytics | Data collection | +| Onboarding | form → team A → team B → team A | Multi-team handoff | + +"New from template" in the team-admin workflow creation flow. +Templates are read-only platform data, not user-editable. They +serve as starting points — clone and customize. + +### FE changes + +- "Duplicate" button in workflow editor header +- "New from Template" option in workflow creation flow +- Template picker modal with descriptions + +--- + +## v0.38.5 — Analytics Dashboard + +Team leads need to answer: how long does each stage take, where are +we bottlenecked, what's our SLA compliance rate. + +### Data source + +No new data collection. Everything is derivable from existing columns: + +- `channels.created_at` — instance start time +- `channels.stage_entered_at` — current stage entry +- `channels.workflow_status` — terminal state +- `workflow_assignments.created_at / claimed_at / completed_at` — assignment lifecycle timestamps + +### Metrics + +| Metric | Derivation | +|--------|------------| +| Throughput | Count of `completed` instances per time period | +| Average cycle time | `completed_at - created_at` across instances | +| Stage dwell time | `next_stage_entered_at - stage_entered_at` (requires computing from history) | +| SLA compliance | `breached / total` per stage with SLA configured | +| Assignment claim time | `claimed_at - created_at` on assignments | +| Queue depth over time | Snapshot of unassigned count (requires periodic sampling or derive from events) | + +### JSX Exemplar + +```jsx +/** + * WorkflowAnalytics — team-admin analytics tab + * + * Mount: team-admin workflows surface, "Analytics" tab + * Data: GET /api/v1/teams/:teamId/workflows/analytics + * ?workflow_id=...&period=7d|30d|90d + */ +function WorkflowAnalytics({ teamId }) { + const [data, setData] = useState(null); + const [workflowId, setWorkflowId] = useState(''); + const [period, setPeriod] = useState('30d'); + + // ... load, filter controls ... + + return ( +
+
+ + +
+ +
+ + + + +
+ + {/* Stage funnel with dwell times — uses existing funnel endpoint + dwell data */} + +
+ ); +} +``` + +### BE changes + +New endpoint: +``` +GET /api/v1/teams/:teamId/workflows/analytics + ?workflow_id=...&period=7d|30d|90d +``` + +Aggregation query over channels + assignments tables. No new tables. +Consider materialized views or periodic snapshot if query cost is +too high on large datasets (unlikely at 50-user scale). + +--- + +## Changeset Budget + +| Version | Estimated CS | Heaviest piece | +|---------|-------------|----------------| +| v0.38.0 | 4 | Form builder component tree (5+ files) | +| v0.38.1 | 2 | Drag-and-drop is fiddly but small | +| v0.38.2 | 3 | SLA scanner + notification plumbing | +| v0.38.3 | 4 | Visitor branding + email + progress | +| v0.38.4 | 2 | Clone is one BE endpoint + FE button | +| v0.38.5 | 3 | Analytics query + chart components | +| **Total** | **~18** | | + +--- + +## Risk Notes + +**v0.38.0 (Form Builder) is the long pole.** If this slips, the MVP +gate phrase "build workflows visually" fails. Prioritize this over +everything else in the series. If time is tight, ship a simplified +version (no fieldsets, no conditional visibility) and add those in a +patch. + +**v0.38.3 (Email) depends on platform SMTP.** If SMTP isn't +configured, the feature is invisible. Consider making the email +config a prerequisite admin step with a setup wizard, or defer email +to v0.38.5 and keep .3 focused on branding + progress. + +**v0.38.5 (Analytics) can be cut.** If the series is running long, +analytics is the most deferrable item — it's nice-to-have, not +gate-blocking. The existing monitor + funnel endpoints cover the +critical "is anything stuck" question. diff --git a/nginx.conf b/nginx.conf index 47465a4..d6f678f 100644 --- a/nginx.conf +++ b/nginx.conf @@ -20,8 +20,7 @@ server { add_header Cache-Control "public, immutable"; } location ~* \.(css|js)$ { - expires 1h; - add_header Cache-Control "public, must-revalidate"; + add_header Cache-Control "no-cache, must-revalidate"; } # ── API + WebSocket → backend ──────────── @@ -97,8 +96,8 @@ server { # v0.29.2: ^~ prefix beats regex; corrected alias to /data/storage/packages/. location ^~ /surfaces/ { alias /data/storage/packages/; - expires 1h; - add_header Cache-Control "public"; + expires off; + add_header Cache-Control "no-cache"; } # Fallback: redirect unknown paths to root diff --git a/packages/dashboard/js/main.js b/packages/dashboard/js/main.js index a30d079..b0f3cc8 100644 --- a/packages/dashboard/js/main.js +++ b/packages/dashboard/js/main.js @@ -227,7 +227,7 @@ let url = '/api/v1/channels?page=1&per_page=12'; if (typeFilter) url += '&type=' + encodeURIComponent(typeFilter); const resp = await sw.api.get(url); - const channels = resp.data || resp || []; + const channels = resp || []; container.innerHTML = ''; if (!channels.length) { @@ -385,7 +385,7 @@ async function _loadAdminCards(container) { try { const resp = await sw.api.get('/api/v1/admin/packages'); - const packages = resp.data || resp || []; + const packages = resp || []; container.innerHTML = ''; packages.forEach(function (pkg) { diff --git a/packages/icd-test-runner/js/crud/channels.js b/packages/icd-test-runner/js/crud/channels.js index f1cd896..8c336f1 100644 --- a/packages/icd-test-runner/js/crud/channels.js +++ b/packages/icd-test-runner/js/crud/channels.js @@ -394,6 +394,66 @@ } } + // ── Channel Types (group, channel) ── + var groupChId = null; + await T.test('crud', 'channels', 'POST /channels (type=group)', async function () { + var d = await T.apiPost('/channels', { + title: testTag + '-group', + type: 'group', + description: 'ICD group test' + }); + T.assertShape(d, T.S.channelFull, 'group channel'); + T.assert(d.type === 'group', 'type should be group, got: ' + d.type); + groupChId = d.id; + T.registerCleanup(function () { if (groupChId) return T.safeDelete('/channels/' + groupChId); }); + }); + + var teamChId = null; + await T.test('crud', 'channels', 'POST /channels (type=channel)', async function () { + var d = await T.apiPost('/channels', { + title: testTag + '-team-channel', + type: 'channel', + description: 'ICD channel test' + }); + T.assertShape(d, T.S.channelFull, 'team channel'); + T.assert(d.type === 'channel', 'type should be channel, got: ' + d.type); + teamChId = d.id; + T.registerCleanup(function () { if (teamChId) return T.safeDelete('/channels/' + teamChId); }); + }); + + // ── Multi-type filter ── + await T.test('crud', 'channels', 'GET /channels?types=group,channel (multi)', async function () { + var d = await T.apiGet('/channels?types=group,channel&per_page=50'); + var arr = d.data || []; + T.assert(Array.isArray(arr), 'expected data array'); + arr.forEach(function (ch) { + T.assert(ch.type === 'group' || ch.type === 'channel', + 'multi-type filter leaked: ' + ch.type); + }); + }); + + await T.test('crud', 'channels', 'GET /channels?types=direct,dm,group,channel (all)', async function () { + var d = await T.apiGet('/channels?types=direct,dm,group,channel&per_page=50'); + var arr = d.data || []; + T.assert(Array.isArray(arr), 'expected data array'); + var types = new Set(arr.map(function (ch) { return ch.type; })); + T.assert(types.size <= 4, 'should only contain known types'); + }); + + // Cleanup channel types + if (groupChId) { + await T.test('crud', 'channels', 'DELETE /channels/:id (group cleanup)', async function () { + await T.safeDelete('/channels/' + groupChId); + groupChId = null; + }); + } + if (teamChId) { + await T.test('crud', 'channels', 'DELETE /channels/:id (channel cleanup)', async function () { + await T.safeDelete('/channels/' + teamChId); + teamChId = null; + }); + } + // ── Folders CRUD + Channel Assignment ── var folderId = null; await T.test('crud', 'channels', 'POST /folders (create)', async function () { diff --git a/packages/icd-test-runner/js/framework.js b/packages/icd-test-runner/js/framework.js index c413fcb..13fe4bf 100644 --- a/packages/icd-test-runner/js/framework.js +++ b/packages/icd-test-runner/js/framework.js @@ -227,16 +227,35 @@ }; // ─── API Wrappers ─────────────────────────────────────────── - // API._get/_post/_put already prepend __BASE__. No base prefix here. + // v0.37.14: raw fetch — old API._* globals removed in scorched earth. - T.apiGet = async function (path) { return await API._get('/api/v1' + path); }; - T.apiPost = async function (path, body) { return await API._post('/api/v1' + path, body); }; - T.apiPut = async function (path, body) { return await API._put('/api/v1' + path, body); }; + async function _fetchJSON(method, path, body) { + var token = await T.getAuthToken(); + var opts = { + method: method, + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + credentials: 'same-origin' + }; + if (body !== undefined && method !== 'GET') opts.body = JSON.stringify(body); + var resp = await fetch(T.base + '/api/v1' + path, opts); + if (resp.status === 204) return { _status: 204 }; + var text = await resp.text(); + var data; + try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; } + data._status = resp.status; + if (!resp.ok) { + var err = new Error(method + ' ' + path + ' → ' + resp.status + (data.error ? ': ' + data.error : '')); + err.status = resp.status; + err.data = data; + throw err; + } + return data; + } - T.apiPatch = async function (path, body) { - if (typeof API._patch === 'function') return await API._patch('/api/v1' + path, body); - return await API._put('/api/v1' + path, body); - }; + T.apiGet = async function (path) { return await _fetchJSON('GET', path); }; + T.apiPost = async function (path, body) { return await _fetchJSON('POST', path, body); }; + T.apiPut = async function (path, body) { return await _fetchJSON('PUT', path, body); }; + T.apiPatch = async function (path, body) { return await _fetchJSON('PATCH', path, body); }; // ─── Token Capture ────────────────────────────────────────── @@ -244,23 +263,8 @@ T.captureAuthToken = async function () { if (_capturedToken) return _capturedToken; - var origFetch = window.fetch; - window.fetch = function (url, opts) { - if (!_capturedToken && opts && opts.headers) { - var auth = ''; - if (opts.headers instanceof Headers) { - auth = opts.headers.get('Authorization') || ''; - } else { - auth = opts.headers['Authorization'] || opts.headers.authorization || ''; - } - if (typeof auth === 'string' && auth.indexOf('Bearer ') === 0) { - _capturedToken = auth.slice(7); - } - } - return origFetch.apply(this, arguments); - }; - try { await API._get('/api/v1/health'); } catch (e) { /* swallow */ } - window.fetch = origFetch; + // v0.37.14: read directly from localStorage (old API._get interceptor removed) + _capturedToken = getAuthTokenFromStorage(); return _capturedToken; }; @@ -291,18 +295,7 @@ // ─── DELETE (needs raw fetch fallback) ────────────────────── T.apiDelete = async function (path) { - if (typeof API._delete === 'function') return await API._delete('/api/v1' + path); - var token = await T.getAuthToken(); - var resp = await fetch(T.base + '/api/v1' + path, { - method: 'DELETE', - headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, - credentials: 'same-origin' - }); - if (resp.status === 204 || resp.status === 200) { - var text = await resp.text(); - return text ? JSON.parse(text) : {}; - } - throw new Error('DELETE ' + path + ' → ' + resp.status); + return await _fetchJSON('DELETE', path); }; T.safeDelete = async function (path, retries) { diff --git a/packages/icd-test-runner/js/main.js b/packages/icd-test-runner/js/main.js index f79f86d..55d413d 100644 --- a/packages/icd-test-runner/js/main.js +++ b/packages/icd-test-runner/js/main.js @@ -19,17 +19,41 @@ * 12. ui.js — Render functions, export, provider setup panel * 11. (this file) — Boot */ -(function () { +(async function () { 'use strict'; var mount = document.getElementById('extension-mount'); if (!mount) return; + // ─── Boot Preact SDK (v0.37.14) ─────────────────────────── + // Extension surfaces don't load Preact vendors or the SDK. + // Load vendors first, then boot SDK so window.sw is available. + try { + var base = window.__BASE__ || ''; + var ver = window.__VERSION__ || '0'; + + // Load Preact vendor modules (required by SDK) + if (!window.preact) { + var { h, render } = await import(base + '/js/sw/vendor/preact.module.js'); + var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js'); + var htmModule = await import(base + '/js/sw/vendor/htm.module.js'); + window.preact = { h, render }; + window.hooks = hooksModule; + window.html = htmModule.default.bind(h); + } + + var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver); + await sdk.boot(); + console.log('[ICD] SDK booted — window.sw ready'); + } catch (e) { + console.warn('[ICD] SDK boot failed:', e.message); + } + // ─── Shared Namespace ─────────────────────────────────────── window.ICD = { mount: mount, manifest: window.__MANIFEST__ || {}, - user: window.__USER__ || {}, + user: window.sw?.auth?.user || window.__USER__ || {}, base: window.__BASE__ || '', // State (populated by framework.js) @@ -101,7 +125,7 @@ return; } var script = document.createElement('script'); - script.src = assetBase + modules[loaded] + '?v=' + (window.__VERSION__ || '0'); + script.src = assetBase + modules[loaded] + '?v=' + (window.__VERSION__ || '0') + '.' + Date.now(); script.onload = function () { loaded++; loadNext(); }; script.onerror = function () { console.error('[ICD] Failed to load: ' + modules[loaded]); diff --git a/packages/icd-test-runner/js/tier-sdk.js b/packages/icd-test-runner/js/tier-sdk.js index f439cf3..d3c5cca 100644 --- a/packages/icd-test-runner/js/tier-sdk.js +++ b/packages/icd-test-runner/js/tier-sdk.js @@ -1,8 +1,9 @@ /** * 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. + * Validates Preact SDK (sw/sdk/index.js) contract: boot, identity, + * REST client, events, theme, pipe registration, execution ordering, + * scoping, halt semantics, error isolation, introspection. + * v0.37.14: Updated from Switchboard.init() to boot() API. */ (function () { 'use strict'; @@ -35,16 +36,16 @@ // 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', 'window.sw exists after boot()', async function () { + T.assert(window.sw !== null && typeof window.sw === 'object', 'window.sw should be an object'); + T.assert(window.sw._sdk, 'window.sw._sdk version marker should exist'); }); - await T.test('sdk', 'boot', 'Double init returns same instance', async function () { - var a = Switchboard.init(); - var b = Switchboard.init(); + await T.test('sdk', 'boot', 'boot() is idempotent', async function () { + var a = window.sw; + // Import and call boot() again — should return same instance + var mod = await import(window.__BASE__ + '/js/sw/sdk/index.js'); + var b = await mod.boot(); T.assert(a === b, 'idempotent: must return same object'); }); @@ -52,16 +53,16 @@ // 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.auth.user populated', async function () { + T.assert(sw.auth.user !== null, 'sw.auth.user should not be null'); + T.assert(typeof sw.auth.user.id === 'string' && sw.auth.user.id.length > 0, 'user.id'); + T.assert(typeof sw.auth.user.username === 'string', 'user.username'); + T.assert(typeof sw.auth.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'); + T.assert(sw.isAdmin === (sw.auth.user.role === 'admin'), 'isAdmin should match role'); }); // ═══════════════════════════════════════════════════════════ @@ -73,10 +74,9 @@ T.assert(d.status === 'ok', 'health status should be ok'); }); - await T.test('sdk', 'api', 'sw.api.get /channels returns envelope', async function () { + await T.test('sdk', 'api', 'sw.api.get /channels returns array', 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'); + T.assert(Array.isArray(d), 'channels should be array (auto-unwrapped)'); }); var _sdkTestChannel = null; @@ -159,8 +159,8 @@ fired = true; T.assert(resolved === 'dark' || resolved === 'light', 'resolved should be dark|light'); }); - // Trigger a theme change event - Events.emit('theme.changed', {}, { localOnly: true }); + // Trigger a theme change event via Preact SDK + sw.emit('theme.changed', {}, { localOnly: true }); T.assert(fired, 'change handler should have fired'); unsub(); }); @@ -425,28 +425,8 @@ 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'); - }); + // v0.37.14: Extension compat shim test removed (Extensions global deleted). + // Extension rendering now goes through Preact SDK pipe directly. // ═══════════════════════════════════════════════════════════ // INTROSPECTION diff --git a/packages/icd-test-runner/js/tier-smoke.js b/packages/icd-test-runner/js/tier-smoke.js index 23a29ec..99a7c88 100644 --- a/packages/icd-test-runner/js/tier-smoke.js +++ b/packages/icd-test-runner/js/tier-smoke.js @@ -418,7 +418,6 @@ T.assertHasKey(d, 'channels', '/admin/channels/archived'); T.assert(Array.isArray(d.channels), 'channels should be array'); T.assertHasKey(d, 'total', '/admin/channels/archived'); - T.assertHasKey(d, 'retention_mode', '/admin/channels/archived'); }); await T.test('smoke', 'admin', 'GET /admin/storage/orphans', async function () { diff --git a/packages/icd-test-runner/js/ui.js b/packages/icd-test-runner/js/ui.js index f8b7e6c..455a0ce 100644 --- a/packages/icd-test-runner/js/ui.js +++ b/packages/icd-test-runner/js/ui.js @@ -97,13 +97,13 @@ style: { alignSelf: 'flex-end', whiteSpace: 'nowrap' }, onClick: function () { if (!T.providerSetup.apiKey) { - if (typeof UI !== 'undefined') UI.toast('Paste an API key first', 'warning'); + if (window.sw && sw.toast) sw.toast('Paste an API key first', 'warning'); return; } T.providerSetup.configured = true; T.renderProviderSetup(); T.renderControls(); - if (typeof UI !== 'undefined') UI.toast(T.providerSetup.provider + ' configured — run Provider tier', 'success'); + if (window.sw && sw.toast) sw.toast(T.providerSetup.provider + ' configured — run Provider tier', 'success'); } }, T.providerSetup.configured ? 'Reconfigure' : 'Configure'); inputRow.appendChild(cfgBtn); @@ -140,12 +140,18 @@ T.mount.style.maxWidth = '1000px'; T.mount.style.margin = '0 auto'; + // Top bar with user menu + var topbar = $('div', { style: { display: 'flex', justifyContent: 'flex-end', marginBottom: '8px' } }); + T.mount.appendChild(topbar); + if (window.sw?.userMenu) window.sw.userMenu(topbar, { flyout: 'down' }); + // Header + var user = window.sw?.auth?.user || T.user || {}; var hdr = $('div', { style: { marginBottom: '24px' } }, [ $('h1', { style: { fontSize: '22px', fontWeight: '700', color: 'var(--text)', margin: '0 0 4px 0' } }, 'ICD Test Runner'), $('div', { style: { fontSize: '13px', color: 'var(--text-3)' } }, - 'v' + (T.manifest.version || '0.28.0') + ' · ' + esc(T.user.username) + (T.user.role === 'admin' ? ' (admin)' : ' (user)')) + 'v' + (T.manifest.version || '0.28.0') + ' · ' + esc(user.username || 'unknown') + (user.role === 'admin' ? ' (admin)' : ' (user)')) ]); T.mount.appendChild(hdr); @@ -232,8 +238,8 @@ // 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' }, + className: (typeof window.sw !== 'undefined') ? 'btn-secondary' : 'btn-ghost', + style: { opacity: (typeof window.sw !== 'undefined') ? '1' : '0.5' }, onClick: function () { T.runSuite('sdk'); } }, 'SDK'); T.el.controls.appendChild(btnSdk); @@ -321,7 +327,7 @@ var pwSpan = $('span', { style: { color: 'var(--warning)', cursor: 'pointer' }, title: 'Click to copy' }, u.password); pwSpan.addEventListener('click', function () { navigator.clipboard.writeText(u.password); - if (typeof UI !== 'undefined') UI.toast('Password copied', 'info'); + if (window.sw && sw.toast) sw.toast('Password copied', 'info'); }); pwTd.appendChild(pwSpan); tr.appendChild(pwTd); @@ -480,7 +486,7 @@ T.exportReport = function (mode) { if (T.results.length === 0) { - if (typeof UI !== 'undefined') UI.toast('No T.results to export — run a suite first', 'warning'); + if (window.sw && sw.toast) sw.toast('No T.results to export — run a suite first', 'warning'); return; } @@ -488,7 +494,7 @@ if (mode === 'clipboard') { navigator.clipboard.writeText(text).then(function () { - if (typeof UI !== 'undefined') UI.toast('Report copied to clipboard', 'success'); + if (window.sw && sw.toast) sw.toast('Report copied to clipboard', 'success'); }).catch(function () { // Fallback: select-all textarea clipboardFallback(text); @@ -505,7 +511,7 @@ a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); - if (typeof UI !== 'undefined') UI.toast('Downloaded ' + filename, 'success'); + if (window.sw && sw.toast) sw.toast('Downloaded ' + filename, 'success'); } } @@ -517,7 +523,7 @@ ta.select(); try { document.execCommand('copy'); } catch (e) { /* give up */ } document.body.removeChild(ta); - if (typeof UI !== 'undefined') UI.toast('Report copied (fallback)', 'info'); + if (window.sw && sw.toast) sw.toast('Report copied (fallback)', 'info'); } @@ -525,15 +531,15 @@ T.runSuite = async function (which) { if (T.running) return; if (which === 'authz' && !T.fixtures.ready) { - if (typeof UI !== 'undefined') UI.toast('Provision test fixtures first (button on the right)', 'warning'); + if (window.sw && sw.toast) sw.toast('Provision test fixtures first (button on the right)', 'warning'); return; } if (which === 'security' && !T.fixtures.ready) { - if (typeof UI !== 'undefined') UI.toast('Provision test fixtures first (button on the right)', 'warning'); + if (window.sw && sw.toast) sw.toast('Provision test fixtures first (button on the right)', 'warning'); return; } if (which === 'provider' && !T.providerSetup.configured) { - if (typeof UI !== 'undefined') UI.toast('Configure a provider (type + API key) in the panel above', 'warning'); + if (window.sw && sw.toast) sw.toast('Configure a provider (type + API key) in the panel above', 'warning'); return; } T.running = true; @@ -571,7 +577,7 @@ var critical = T.results.filter(function (r) { return r.status === 'fail' && r.detail && r.detail.indexOf('CRITICAL') !== -1; }).length; var msg = pass + ' passed, ' + fail + ' failed'; if (critical > 0) msg += ' (' + critical + ' CRITICAL)'; - UI.toast(msg, critical > 0 ? 'error' : fail > 0 ? 'warning' : 'success'); + if (window.sw && sw.toast) sw.toast(msg, critical > 0 ? 'error' : fail > 0 ? 'warning' : 'success'); } } diff --git a/server/database/migrations/001_core.sql b/server/database/migrations/001_core.sql index 8b59c49..a346f42 100644 --- a/server/database/migrations/001_core.sql +++ b/server/database/migrations/001_core.sql @@ -138,7 +138,8 @@ INSERT INTO global_settings (key, value) VALUES "utility": { "primary": null, "fallback": null }, "embedding": { "primary": null, "fallback": null }, "generation": { "primary": null, "fallback": null } - }'::jsonb) + }'::jsonb), + ('retention_ttl_days', '{"value": 0}'::jsonb) ON CONFLICT (key) DO NOTHING; diff --git a/server/database/migrations/005_channels.sql b/server/database/migrations/005_channels.sql index 82d7a9a..6948884 100644 --- a/server/database/migrations/005_channels.sql +++ b/server/database/migrations/005_channels.sql @@ -81,6 +81,9 @@ CREATE TABLE IF NOT EXISTS channels ( project_id UUID, workspace_id UUID, + -- Retention (v0.37.14) + purge_after TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); @@ -95,6 +98,7 @@ CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id) WHERE pr CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id) WHERE workspace_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_channels_workflow ON channels(workflow_id) WHERE workflow_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_channels_workflow_status ON channels(workflow_status) WHERE workflow_status = 'active'; +CREATE INDEX IF NOT EXISTS idx_channels_purge_after ON channels(purge_after) WHERE purge_after IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_channels_workflow_active ON channels(workflow_id, workflow_status) WHERE workflow_id IS NOT NULL AND workflow_status = 'active'; DROP TRIGGER IF EXISTS channels_updated_at ON channels; diff --git a/server/database/migrations/sqlite/001_core.sql b/server/database/migrations/sqlite/001_core.sql index 5b0fa17..c20c9a2 100644 --- a/server/database/migrations/sqlite/001_core.sql +++ b/server/database/migrations/sqlite/001_core.sql @@ -78,7 +78,8 @@ INSERT OR IGNORE INTO global_settings (key, value) VALUES ('registration', '{"enabled": true}'), ('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'), ('banner', '{"enabled": false, "text": "", "bg": "#007a33", "fg": "#ffffff"}'), - ('model_roles', '{"utility":{"primary":null,"fallback":null},"embedding":{"primary":null,"fallback":null},"generation":{"primary":null,"fallback":null}}'); + ('model_roles', '{"utility":{"primary":null,"fallback":null},"embedding":{"primary":null,"fallback":null},"generation":{"primary":null,"fallback":null}}'), + ('retention_ttl_days', '{"value": 0}'); CREATE TABLE IF NOT EXISTS user_presence ( user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, diff --git a/server/database/migrations/sqlite/005_channels.sql b/server/database/migrations/sqlite/005_channels.sql index f5e4376..6456ef4 100644 --- a/server/database/migrations/sqlite/005_channels.sql +++ b/server/database/migrations/sqlite/005_channels.sql @@ -46,6 +46,7 @@ CREATE TABLE IF NOT EXISTS channels ( kb_auto_inject INTEGER NOT NULL DEFAULT 0, project_id TEXT, workspace_id TEXT, + purge_after TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); diff --git a/server/events/types.go b/server/events/types.go index d4c8053..697ffc2 100644 --- a/server/events/types.go +++ b/server/events/types.go @@ -53,6 +53,7 @@ var routeTable = map[string]Direction{ "user.mentioned": DirToClient, // v0.23.2: targeted @mention notification "typing.user": DirToClient, // v0.23.2: human typing in DM/channel "message.created": DirToClient, // v0.23.2: chained/DM message delivery + "message.deleted": DirToClient, // v0.37.14: soft-delete broadcast // System "system.notify": DirToClient, diff --git a/server/events/ws.go b/server/events/ws.go index 33cd1f6..b8b424c 100644 --- a/server/events/ws.go +++ b/server/events/ws.go @@ -251,7 +251,7 @@ func (c *Conn) subscribeToBus() { } // Don't echo typing events back to the sender - if e.Label == "chat.typing" || e.ConnID == c.id { + if strings.HasPrefix(e.Label, "chat.typing.") || strings.HasPrefix(e.Label, "channel.typing.") { if e.SenderID == c.userID && e.ConnID == c.id { return } diff --git a/server/handlers/admin.go b/server/handlers/admin.go index b8c2b16..36a4708 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -65,7 +65,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"users": users, "total": total}) + c.JSON(http.StatusOK, gin.H{"data": users, "total": total}) } func (h *AdminHandler) CreateUser(c *gin.Context) { @@ -399,11 +399,11 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) { hasAdminPrompt = true } - // Channel retention mode (v0.23.2 — flexible or retain) - retentionMode := "flexible" - if retCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "channel_retention"); err == nil { - if mode, ok := retCfg["mode"].(string); ok && mode != "" { - retentionMode = mode + // Retention TTL (v0.37.14 — days before purge for global/team channels) + retentionTTL := 0 + if ttlCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "retention_ttl_days"); err == nil { + if v, ok := ttlCfg["value"].(float64); ok { + retentionTTL = int(v) } } @@ -417,7 +417,7 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) { "allow_registration": policies["allow_registration"], "allow_user_byok": policies["allow_user_byok"], "allow_user_personas": policies["allow_user_personas"], - "channel_retention_mode": retentionMode, + "retention_ttl_days": retentionTTL, }, }) } @@ -459,7 +459,7 @@ func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) { HasKey: cfg.HasKey(), } } - c.JSON(http.StatusOK, gin.H{"configs": out}) + c.JSON(http.StatusOK, gin.H{"data": out}) } func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) { @@ -601,7 +601,7 @@ func (h *AdminHandler) ListModelConfigs(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"}) return } - c.JSON(http.StatusOK, gin.H{"models": entries}) + c.JSON(http.StatusOK, gin.H{"data": entries}) } func (h *AdminHandler) FetchModels(c *gin.Context) { @@ -802,19 +802,19 @@ func (h *AdminHandler) ListArchivedChannels(c *gin.Context) { } // Retention config - retentionMode := "flexible" - if retCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "channel_retention"); err == nil { - if mode, ok := retCfg["mode"].(string); ok { - retentionMode = mode + retentionTTL := 0 + if ttlCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "retention_ttl_days"); err == nil { + if v, ok := ttlCfg["value"].(float64); ok { + retentionTTL = int(v) } } c.JSON(http.StatusOK, gin.H{ - "channels": channels, - "total": total, - "page": page, - "per_page": perPage, - "retention_mode": retentionMode, + "data": channels, + "total": total, + "page": page, + "per_page": perPage, + "retention_ttl_days": retentionTTL, }) } diff --git a/server/handlers/apiconfigs.go b/server/handlers/apiconfigs.go index 680cfb1..ffb2b43 100644 --- a/server/handlers/apiconfigs.go +++ b/server/handlers/apiconfigs.go @@ -64,7 +64,7 @@ func (h *ProviderConfigHandler) ListConfigs(c *gin.Context) { } } - c.JSON(http.StatusOK, gin.H{"configs": out}) + c.JSON(http.StatusOK, gin.H{"data": out}) } // GetConfig returns a single config by ID (if user has access). @@ -248,7 +248,7 @@ func (h *ProviderConfigHandler) ListModels(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"models": entries}) + c.JSON(http.StatusOK, gin.H{"data": entries}) } // FetchModels fetches models from the provider API and auto-enables them. diff --git a/server/handlers/auth.go b/server/handlers/auth.go index fb3158a..27e08c4 100644 --- a/server/handlers/auth.go +++ b/server/handlers/auth.go @@ -154,6 +154,9 @@ func (h *AuthHandler) Logout(c *gin.Context) { h.uekCache.Evict(userID.(string)) } + // Clear the sb_token cookie so SSR middleware doesn't trust a stale token + c.SetCookie("sb_token", "", -1, "/", "", false, false) + c.JSON(http.StatusOK, gin.H{"message": "logged out"}) } diff --git a/server/handlers/channel_models.go b/server/handlers/channel_models.go index a441cd9..f977571 100644 --- a/server/handlers/channel_models.go +++ b/server/handlers/channel_models.go @@ -46,7 +46,7 @@ func (h *ChannelModelHandler) List(c *gin.Context) { if roster == nil { roster = []models.ChannelModel{} } - c.JSON(http.StatusOK, gin.H{"models": roster}) + c.JSON(http.StatusOK, gin.H{"data": roster}) } // ── Add ────────────────────────────────────── @@ -110,7 +110,7 @@ func (h *ChannelModelHandler) Add(c *gin.Context) { // Return the updated roster roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) if roster == nil { roster = []models.ChannelModel{} } - c.JSON(http.StatusCreated, gin.H{"models": roster}) + c.JSON(http.StatusCreated, gin.H{"data": roster}) } // ── Update ─────────────────────────────────── @@ -177,7 +177,7 @@ func (h *ChannelModelHandler) Update(c *gin.Context) { roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) if roster == nil { roster = []models.ChannelModel{} } - c.JSON(http.StatusOK, gin.H{"models": roster}) + c.JSON(http.StatusOK, gin.H{"data": roster}) } // ── Delete ─────────────────────────────────── @@ -222,7 +222,7 @@ func (h *ChannelModelHandler) Delete(c *gin.Context) { roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) if roster == nil { roster = []models.ChannelModel{} } - c.JSON(http.StatusOK, gin.H{"models": roster}) + c.JSON(http.StatusOK, gin.H{"data": roster}) } // ── Helpers ────────────────────────────────── diff --git a/server/handlers/channels.go b/server/handlers/channels.go index cd93724..02b951c 100644 --- a/server/handlers/channels.go +++ b/server/handlers/channels.go @@ -1,14 +1,19 @@ package handlers import ( + "context" "encoding/json" + "fmt" + "log" "math" "net/http" "strconv" "strings" + "time" "github.com/gin-gonic/gin" + "chat-switchboard/database" "chat-switchboard/models" "chat-switchboard/store" ) @@ -218,6 +223,9 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { channels = append(channels, listItemToResponse(item)) } + // Resolve DM titles: show the other participant's name, not the creator's label + resolveDMTitles(c.Request.Context(), channels, userID) + SafeJSON(c, http.StatusOK, paginatedResponse{ Data: channels, Page: page, @@ -346,15 +354,29 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) { return } - // Verify ownership + // Verify ownership (participants can only move to folder) owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } if !owns { - c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) - return + // Participants may move channels to their own folders + folderOnly := req.FolderID != nil && req.Title == nil && req.Description == nil && + req.Model == nil && req.SystemPrompt == nil && req.ProviderConfigID == nil && + req.IsArchived == nil && req.IsPinned == nil && req.Folder == nil && + req.Tags == nil && req.WorkspaceID == nil && req.AiMode == nil && + req.Topic == nil && req.Settings == nil + if !folderOnly { + c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) + return + } + // Verify they are a participant + canAccess, _ := h.stores.Channels.UserCanAccess(ctx, channelID, userID) + if !canAccess { + c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) + return + } } // Build fields map for store.Update @@ -455,18 +477,51 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) { func (h *ChannelHandler) DeleteChannel(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") + ctx := c.Request.Context() - n, err := h.stores.Channels.DeleteByOwner(c.Request.Context(), channelID, userID) + // Check ownership first (without deleting) + owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"}) + c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } - if n == 0 { + if !owns { + // Not the owner — if participant, leave the channel instead of deleting. + res, _ := database.DB.ExecContext(ctx, database.Q(` + DELETE FROM channel_participants + WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2 + `), channelID, userID) + if rows, _ := res.RowsAffected(); rows > 0 { + c.JSON(http.StatusOK, gin.H{"message": "left channel"}) + return + } c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } - // Clean up storage files (CASCADE already removed PG file rows) + // Check if retention policy applies (global/team provider + TTL > 0) + if h.shouldRetain(ctx, channelID) { + ttl := h.retentionTTL(ctx) + purgeAfter := time.Now().Add(time.Duration(ttl) * 24 * time.Hour) + if err := h.stores.Channels.ArchiveForRetention(ctx, channelID, purgeAfter); err != nil { + log.Printf("[retention] ArchiveForRetention(%s) error: %v", channelID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to archive channel"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "channel archived for retention", + "purge_after": purgeAfter.UTC().Format("2006-01-02T15:04:05Z"), + }) + return + } + + // Exempt — hard delete + n, err := h.stores.Channels.DeleteByOwner(ctx, channelID, userID) + if err != nil || n == 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"}) + return + } + if channelDeleteHook != nil { go channelDeleteHook(channelID) } @@ -474,6 +529,45 @@ func (h *ChannelHandler) DeleteChannel(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "channel deleted"}) } +// shouldRetain returns true if a channel uses a global or team provider +// and the retention TTL is configured (> 0). +func (h *ChannelHandler) shouldRetain(ctx context.Context, channelID string) bool { + ttl := h.retentionTTL(ctx) + if ttl <= 0 { + return false + } + + // Only BYOK (personal) provider channels are exempt. + // All others — global, team, or no explicit provider — follow retention. + var providerConfigID *string + _ = database.DB.QueryRowContext(ctx, database.Q(` + SELECT provider_config_id FROM channels WHERE id = $1 + `), channelID).Scan(&providerConfigID) + + if providerConfigID == nil || *providerConfigID == "" { + return true // no explicit provider → defaults to global → retain + } + + cfg, err := h.stores.Providers.GetByID(ctx, *providerConfigID) + if err != nil { + return true // provider deleted (FK SET NULL already handled above) → retain + } + + return cfg.Scope != models.ScopePersonal +} + +// retentionTTL reads the configured retention TTL in days from global settings. +func (h *ChannelHandler) retentionTTL(ctx context.Context) int { + cfg, err := h.stores.GlobalConfig.Get(ctx, "retention_ttl_days") + if err != nil { + return 0 + } + if v, ok := cfg["value"].(float64); ok { + return int(v) + } + return 0 +} + // ── Mark Read (v0.23.2) ─────────────────────── func (h *ChannelHandler) MarkRead(c *gin.Context) { @@ -487,3 +581,60 @@ func (h *ChannelHandler) MarkRead(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) } + +// ── DM Title Resolution ────────────────────── + +// resolveDMTitles replaces DM channel titles with the other participant's +// display name so each user sees "DM " instead of the +// creator's original label. +func resolveDMTitles(ctx context.Context, channels []channelResponse, viewerID string) { + // Collect DM channel IDs + var dmIDs []string + dmIdx := map[string]int{} // channel_id → index in channels slice + for i, ch := range channels { + if ch.Type == "dm" { + dmIDs = append(dmIDs, ch.ID) + dmIdx[ch.ID] = i + } + } + if len(dmIDs) == 0 { + return + } + + // Query: for each DM, get the OTHER participant's display name + // Uses channel_participants to find the peer, then joins users for name + placeholders := make([]string, len(dmIDs)) + args := make([]interface{}, 0, len(dmIDs)+1) + for i, id := range dmIDs { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args = append(args, id) + } + args = append(args, viewerID) + viewerPlaceholder := fmt.Sprintf("$%d", len(args)) + + query := database.Q(fmt.Sprintf(` + SELECT cp.channel_id, + COALESCE(NULLIF(u.display_name, ''), u.username, 'Unknown') + FROM channel_participants cp + JOIN users u ON u.id = cp.participant_id + WHERE cp.channel_id IN (%s) + AND cp.participant_type = 'user' + AND cp.participant_id != %s + LIMIT %d + `, strings.Join(placeholders, ","), viewerPlaceholder, len(dmIDs))) + + rows, err := database.DB.QueryContext(ctx, query, args...) + if err != nil { + return // non-fatal: keep original titles + } + defer rows.Close() + + for rows.Next() { + var chID, peerName string + if rows.Scan(&chID, &peerName) == nil { + if idx, ok := dmIdx[chID]; ok { + channels[idx].Title = "DM " + peerName + } + } + } +} diff --git a/server/handlers/completion.go b/server/handlers/completion.go index d97d3b9..4581d81 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -267,46 +267,14 @@ func (h *CompletionHandler) Complete(c *gin.Context) { SELECT COALESCE(ai_mode, 'auto') FROM channels WHERE id = $1 `), channelID).Scan(&aiMode) - if aiMode == "off" { - c.JSON(http.StatusForbidden, gin.H{"error": "AI responses are disabled for this channel"}) - return - } - if aiMode == "mention_only" && extractFirstMention(req.Content) == "" { + if aiMode == "off" || (aiMode == "mention_only" && extractFirstMention(req.Content) == "") { // Persist the user message before returning msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil, "", "") if err != nil { - log.Printf("[mention_only] Failed to persist user message: %v", err) + log.Printf("[ai_skip] Failed to persist user message: %v", err) } - // Broadcast via WS to ALL user participants (not just sender) if h.hub != nil && msgID != "" { - payload, _ := json.Marshal(map[string]any{ - "id": msgID, - "channel_id": channelID, - "role": "user", - "content": req.Content, - "user_id": userID, - }) - evt := events.Event{ - Label: "message.created", - Payload: payload, - Ts: time.Now().UnixMilli(), - } - // Send to sender - h.hub.PublishToUser(userID, evt) - // Send to other user participants in the channel - pRows, pErr := database.DB.QueryContext(c.Request.Context(), database.Q(` - SELECT participant_id FROM channel_participants - WHERE channel_id = $1 AND participant_type = 'user' AND participant_id != $2 - `), channelID, userID) - if pErr == nil { - defer pRows.Close() - for pRows.Next() { - var pid string - if pRows.Scan(&pid) == nil { - h.hub.PublishToUser(pid, evt) - } - } - } + broadcastUserMessage(c.Request.Context(), h.hub, channelID, msgID, req.Content, userID) } c.JSON(http.StatusOK, gin.H{"status": "delivered", "ai_skipped": true, "message_id": msgID}) return @@ -532,6 +500,11 @@ func (h *CompletionHandler) Complete(c *gin.Context) { log.Printf("Failed to persist user message: %v", err) } + // Broadcast user message to all channel participants + if h.hub != nil && msgID != "" { + broadcastUserMessage(c.Request.Context(), h.hub, channelID, msgID, req.Content, userID) + } + // Link files to the persisted message if msgID != "" && len(req.FileIDs) > 0 { for _, fID := range req.FileIDs { @@ -963,7 +936,7 @@ func (h *CompletionHandler) ListTools(c *gin.Context) { } } - c.JSON(http.StatusOK, gin.H{"tools": result}) + c.JSON(http.StatusOK, gin.H{"data": result}) } // ── Streaming Completion (SSE) with Tool Loop ── @@ -985,10 +958,16 @@ func (h *CompletionHandler) streamCompletion( // Persist assistant response if result.Content != "" { - if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID); err != nil { + asstMsgID, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID) + if err != nil { log.Printf("Failed to persist assistant message: %v", err) } + // Broadcast assistant message to other channel participants + if h.hub != nil && asstMsgID != "" { + broadcastAssistantMessage(c.Request.Context(), h.hub, channelID, asstMsgID, result.Content, model, userID, personaID) + } + // AI-to-AI chaining: if the response @mentions another persona participant, // trigger a follow-up completion asynchronously via WebSocket delivery. go func() { diff --git a/server/handlers/completion_chain.go b/server/handlers/completion_chain.go index ec8fcab..0478342 100644 --- a/server/handlers/completion_chain.go +++ b/server/handlers/completion_chain.go @@ -160,26 +160,9 @@ func (h *CompletionHandler) chainIfMentioned( return } - // Deliver via WebSocket + // Deliver via WebSocket to all channel participants if h.hub != nil { - payload, _ := json.Marshal(map[string]interface{}{ - "id": msgID, - "channel_id": channelID, - "role": "assistant", - "content": resp.Content, - "model": model, - "participant_type": "persona", - "participant_id": mentionPersona.ID, - "display_name": mentionPersona.Name, - "avatar": mentionPersona.Avatar, - "tokens_used": resp.InputTokens + resp.OutputTokens, - "chain_depth": depth + 1, - }) - h.hub.PublishToUser(userID, events.Event{ - Label: "message.created", - Payload: payload, - Ts: time.Now().UnixMilli(), - }) + broadcastAssistantMessage(context.Background(), h.hub, channelID, msgID, resp.Content, model, "", mentionPersona.ID) } // Log usage @@ -301,24 +284,7 @@ func (h *CompletionHandler) chainToPersona(channelID, userID, personaID, trigger } if h.hub != nil { - payload, _ := json.Marshal(map[string]interface{}{ - "id": msgID, - "channel_id": channelID, - "role": "assistant", - "content": resp.Content, - "model": model, - "participant_type": "persona", - "participant_id": persona.ID, - "display_name": persona.Name, - "avatar": persona.Avatar, - "tokens_used": resp.InputTokens + resp.OutputTokens, - "chain_depth": depth, - }) - h.hub.PublishToUser(userID, events.Event{ - Label: "message.created", - Payload: payload, - Ts: time.Now().UnixMilli(), - }) + broadcastAssistantMessage(context.Background(), h.hub, channelID, msgID, resp.Content, model, "", persona.ID) } if h.stores.Usage != nil { diff --git a/server/handlers/folders.go b/server/handlers/folders.go index 5b19850..6e033b8 100644 --- a/server/handlers/folders.go +++ b/server/handlers/folders.go @@ -5,6 +5,7 @@ package handlers // v0.29.0: Raw SQL replaced with FolderStore methods. import ( + "encoding/json" "net/http" "strings" @@ -30,14 +31,15 @@ func (h *FolderHandler) List(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"}) return } - c.JSON(http.StatusOK, gin.H{"folders": folders}) + c.JSON(http.StatusOK, gin.H{"data": folders}) } func (h *FolderHandler) Create(c *gin.Context) { userID := getUserID(c) var req struct { - Name string `json:"name" binding:"required"` - SortOrder int `json:"sort_order"` + Name string `json:"name" binding:"required"` + ParentID *string `json:"parent_id"` + SortOrder int `json:"sort_order"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -52,6 +54,7 @@ func (h *FolderHandler) Create(c *gin.Context) { f := &models.Folder{ UserID: userID, Name: req.Name, + ParentID: req.ParentID, SortOrder: req.SortOrder, } if err := h.stores.Folders.Create(c.Request.Context(), f); err != nil { @@ -64,11 +67,19 @@ func (h *FolderHandler) Create(c *gin.Context) { func (h *FolderHandler) Update(c *gin.Context) { userID := getUserID(c) folderID := c.Param("id") - var req struct { - Name string `json:"name"` - SortOrder *int `json:"sort_order"` + + // Read raw body to detect which fields were explicitly sent + data, err := c.GetRawData() + if err != nil || len(data) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return } - if err := c.ShouldBindJSON(&req); err != nil { + var req struct { + Name string `json:"name"` + SortOrder *int `json:"sort_order"` + ParentID *string `json:"parent_id"` + } + if err := json.Unmarshal(data, &req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -76,7 +87,15 @@ func (h *FolderHandler) Update(c *gin.Context) { req.Name = strings.TrimSpace(req.Name) } - n, err := h.stores.Folders.Update(c.Request.Context(), folderID, userID, req.Name, req.SortOrder) + // Only update parent_id if it was explicitly present in the JSON + var parentID **string + var raw map[string]json.RawMessage + _ = json.Unmarshal(data, &raw) + if _, ok := raw["parent_id"]; ok { + parentID = &req.ParentID + } + + n, err := h.stores.Folders.Update(c.Request.Context(), folderID, userID, req.Name, req.SortOrder, parentID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update folder"}) return diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go index 0ba4ac7..0a20e2e 100644 --- a/server/handlers/integration_test.go +++ b/server/handlers/integration_test.go @@ -252,6 +252,10 @@ func setupHarness(t *testing.T) *testHarness { permH := NewProfilePermissionsHandler(stores) protected.GET("/profile/permissions", permH.GetMyPermissions) + // Boot payload (v0.37.15) + bootH := NewProfileBootstrapHandler(stores) + protected.GET("/profile/bootstrap", bootH.GetBootstrap) + // Profile / Settings settings := NewSettingsHandler(stores, nil) protected.GET("/profile", settings.GetProfile) @@ -518,6 +522,10 @@ func (h *testHarness) registerUser(username, email, password string) (userID, to var resp map[string]interface{} decode(w, &resp) token, _ = resp["access_token"].(string) + if token == "" { + h.t.Fatalf("register %s: no access_token in response (user inactive? default_user_active policy may not have committed): %s", + username, w.Body.String()) + } // Extract user_id from profile w2 := h.request("GET", "/api/v1/profile", token, nil) @@ -605,9 +613,9 @@ func TestIntegration_AdminListUsers(t *testing.T) { } var resp map[string]interface{} decode(w, &resp) - users, ok := resp["users"].([]interface{}) + users, ok := resp["data"].([]interface{}) if !ok { - t.Fatalf("response must have 'users' array, got %T", resp["users"]) + t.Fatalf("response must have 'data' array, got %T", resp["data"]) } if len(users) < 1 { t.Fatal("should have at least 1 user (admin)") @@ -906,10 +914,10 @@ func TestIntegration_TeamMemberManagement(t *testing.T) { } var usersResp map[string]interface{} decode(w, &usersResp) - if _, ok := usersResp["users"]; !ok { - t.Fatal("admin/users response MUST have 'users' key (not 'data')") + if _, ok := usersResp["data"]; !ok { + t.Fatal("admin/users response MUST have 'data' key") } - users := usersResp["users"].([]interface{}) + users := usersResp["data"].([]interface{}) if len(users) < 2 { t.Fatalf("expected at least 2 users (admin + alice), got %d", len(users)) } @@ -1166,13 +1174,13 @@ func TestIntegration_AdminModelFetchEnableUserSees(t *testing.T) { } var adminResp map[string]interface{} decode(w, &adminResp) - adminModels := adminResp["models"].([]interface{}) + adminModels := adminResp["data"].([]interface{}) if len(adminModels) != 3 { t.Fatalf("admin should see 3 disabled models, got %d", len(adminModels)) } - // Verify admin response is non-null array (not {"models": null}) - if adminResp["models"] == nil { + // Verify admin response is non-null array (not {"data": null}) + if adminResp["data"] == nil { t.Fatal("admin models must be [] not null — causes frontend fallback chain to break") } @@ -1863,7 +1871,7 @@ func TestUserJourney_FullMatrix(t *testing.T) { } var resp map[string]interface{} decode(w, &resp) - allModels := resp["models"].([]interface{}) + allModels := resp["data"].([]interface{}) // Admin should see only global-scope models (3), not team(2) or BYOK(2) if len(allModels) != 3 { t.Fatalf("admin/models should show 3 global entries, got %d", len(allModels)) @@ -2004,12 +2012,8 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) { } var roleResp map[string]interface{} decode(w, &roleResp) - roleData, _ := roleResp["data"].(map[string]interface{}) - if roleData == nil { - roleData = map[string]interface{}{} - } - if len(roleData) != 0 { - t.Fatalf("team roles should be empty initially, got %d", len(roleData)) + if len(roleResp) != 0 { + t.Fatalf("team roles should be empty initially, got %d", len(roleResp)) } // Set team role override @@ -2028,11 +2032,10 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) { w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil) roleResp = map[string]interface{}{} decode(w, &roleResp) - roleData, _ = roleResp["data"].(map[string]interface{}) - if roleData == nil || len(roleData) == 0 { + if len(roleResp) == 0 { t.Fatal("team roles should have utility after update") } - if _, ok := roleData["utility"]; !ok { + if _, ok := roleResp["utility"]; !ok { t.Fatal("team roles should have utility after update") } @@ -2046,11 +2049,8 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) { w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil) roleResp = map[string]interface{}{} decode(w, &roleResp) - roleData, _ = roleResp["data"].(map[string]interface{}) - if roleData != nil { - if _, ok := roleData["utility"]; ok { - t.Fatal("team roles should not have utility after delete") - } + if _, ok := roleResp["utility"]; ok { + t.Fatal("team roles should not have utility after delete") } } @@ -3749,7 +3749,7 @@ func TestIntegration_Messages_TreePath(t *testing.T) { } var pathEnv map[string]interface{} decode(w, &pathEnv) - pathResp := pathEnv["messages"].([]interface{}) + pathResp := pathEnv["data"].([]interface{}) if len(pathResp) != 3 { t.Fatalf("expected 3 messages in path, got %d", len(pathResp)) } @@ -4291,8 +4291,8 @@ func TestAudit_TeamProvidersEnvelope(t *testing.T) { } // ── H9: Team Roles Envelope ──────────────── -// BUG: ListTeamRoles returns bare map, not wrapped in {"data": ...}. - +// Roles endpoints return composite named-key maps (not lists). +// Convention: composite endpoints return object directly, no {"data": ...} wrapper. func TestAudit_TeamRolesEnvelope(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") @@ -4318,10 +4318,10 @@ func TestAudit_TeamRolesEnvelope(t *testing.T) { var resp map[string]interface{} decode(w, &resp) - // Should be wrapped in {"data": {...}} per composite convention - if _, ok := resp["data"]; !ok { - t.Fatalf("H9 BUG: GET /teams/:teamId/roles should return 'data' key (composite convention), "+ - "got keys: %v — bare map returned without wrapper", mapKeys(resp)) + // Composite endpoint — must NOT have a "data" wrapper + if _, ok := resp["data"]; ok { + t.Fatalf("GET /teams/:teamId/roles should return named keys directly (composite convention), "+ + "not wrapped in {\"data\": ...}") } } diff --git a/server/handlers/live_provider_test.go b/server/handlers/live_provider_test.go index c02bd8e..9863899 100644 --- a/server/handlers/live_provider_test.go +++ b/server/handlers/live_provider_test.go @@ -197,7 +197,7 @@ func trySetupProvider(h *testHarness, adminToken string, pc liveProviderConfig) var fallbackCatalogID, fallbackModelID string var toolCapableCatalogID, toolCapableModelID string - for _, raw := range modelsResp["models"].([]interface{}) { + for _, raw := range modelsResp["data"].([]interface{}) { m := raw.(map[string]interface{}) if m["visibility"].(string) != "disabled" { continue @@ -318,7 +318,7 @@ func setupProviderWithModel(t *testing.T, h *testHarness, adminToken string, pc var catalogID, modelID string var fallbackCatalogID, fallbackModelID string - for _, raw := range modelsResp["models"].([]interface{}) { + for _, raw := range modelsResp["data"].([]interface{}) { m := raw.(map[string]interface{}) if m["visibility"].(string) != "disabled" { continue @@ -410,7 +410,7 @@ func TestLive_ProviderFullFlow(t *testing.T) { w = h.request("GET", "/api/v1/admin/models", adminToken, nil) var modelsResp map[string]interface{} decode(w, &modelsResp) - catalogModels := modelsResp["models"].([]interface{}) + catalogModels := modelsResp["data"].([]interface{}) var enableID, enableModelID string var fallbackID, fallbackModelID string @@ -518,7 +518,7 @@ func TestLive_FetchModelsCapabilities(t *testing.T) { var resp map[string]interface{} decode(w, &resp) - for _, raw := range resp["models"].([]interface{}) { + for _, raw := range resp["data"].([]interface{}) { m := raw.(map[string]interface{}) caps, ok := m["capabilities"].(map[string]interface{}) if !ok { diff --git a/server/handlers/messages.go b/server/handlers/messages.go index 923adc9..caf439b 100644 --- a/server/handlers/messages.go +++ b/server/handlers/messages.go @@ -1,17 +1,20 @@ package handlers import ( + "context" "database/sql" "encoding/json" "fmt" "log" "math" "net/http" + "time" "github.com/gin-gonic/gin" capspkg "chat-switchboard/capabilities" "chat-switchboard/crypto" + "chat-switchboard/database" "chat-switchboard/events" "chat-switchboard/models" "chat-switchboard/providers" @@ -153,7 +156,7 @@ func (h *MessageHandler) GetActivePath(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"messages": path}) + c.JSON(http.StatusOK, gin.H{"data": path}) } // ── Create Message (manual) ───────────────── @@ -218,6 +221,11 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) { return } + // Broadcast via WS to channel participants + if h.hub != nil && msg.Role == "user" { + broadcastUserMessage(c.Request.Context(), h.hub, channelID, msg.ID, msg.Content, userID) + } + resp := messageResponse{ ID: msg.ID, ChannelID: msg.ChannelID, @@ -579,7 +587,7 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"messages": path, "active_leaf_id": leafID}) + c.JSON(http.StatusOK, gin.H{"data": path, "active_leaf_id": leafID}) } // ── List Siblings ─────────────────────────── @@ -638,3 +646,157 @@ func userOwnsChannel(c *gin.Context, channelID, userID string) bool { } return true } + +// broadcastUserMessage publishes a message.created event to all user +// participants in the channel via WebSocket. +func broadcastUserMessage(ctx context.Context, hub *events.Hub, channelID, msgID, content, senderID string) { + // Look up sender display name + var displayName, username string + _ = database.DB.QueryRowContext(ctx, database.Q(` + SELECT COALESCE(display_name, ''), COALESCE(username, '') FROM users WHERE id = $1 + `), senderID).Scan(&displayName, &username) + + payload, _ := json.Marshal(map[string]any{ + "id": msgID, + "channel_id": channelID, + "role": "user", + "content": content, + "user_id": senderID, + "display_name": displayName, + "username": username, + "created_at": time.Now().UTC().Format("2006-01-02T15:04:05Z"), + }) + evt := events.Event{ + Label: "message.created", + Payload: payload, + Ts: time.Now().UnixMilli(), + } + // Send to all user participants in the channel + rows, err := database.DB.QueryContext(ctx, database.Q(` + SELECT participant_id FROM channel_participants + WHERE channel_id = $1 AND participant_type = 'user' + `), channelID) + if err != nil { + // Fallback: at least send to the sender + hub.PublishToUser(senderID, evt) + return + } + defer rows.Close() + for rows.Next() { + var pid string + if rows.Scan(&pid) == nil { + hub.PublishToUser(pid, evt) + } + } +} + +// broadcastAssistantMessage publishes a message.created event for an assistant +// message to all user participants in the channel (except the requesting user, +// who already received the response via SSE). +func broadcastAssistantMessage(ctx context.Context, hub *events.Hub, channelID, msgID, content, model, senderUserID, personaID string) { + // Look up persona display name if available + var displayName, avatar string + if personaID != "" { + _ = database.DB.QueryRowContext(ctx, database.Q(` + SELECT COALESCE(name, ''), COALESCE(avatar, '') FROM personas WHERE id = $1 + `), personaID).Scan(&displayName, &avatar) + } + if displayName == "" { + displayName = model + } + + payload, _ := json.Marshal(map[string]any{ + "id": msgID, + "channel_id": channelID, + "role": "assistant", + "content": content, + "model": model, + "participant_type": "persona", + "participant_id": personaID, + "display_name": displayName, + "avatar": avatar, + "created_at": time.Now().UTC().Format("2006-01-02T15:04:05Z"), + }) + evt := events.Event{ + Label: "message.created", + Payload: payload, + Ts: time.Now().UnixMilli(), + } + // Send to all user participants except the sender (who got SSE) + rows, err := database.DB.QueryContext(ctx, database.Q(` + SELECT participant_id FROM channel_participants + WHERE channel_id = $1 AND participant_type = 'user' + `), channelID) + if err != nil { + return + } + defer rows.Close() + for rows.Next() { + var pid string + if rows.Scan(&pid) == nil && pid != senderUserID { + hub.PublishToUser(pid, evt) + } + } +} + +// ── Delete Message ────────────────────────────────────────── + +// DeleteMessage soft-deletes a message. +// DELETE /channels/:id/messages/:msgId +func (h *MessageHandler) DeleteMessage(c *gin.Context) { + userID := getUserID(c) + channelID := c.Param("id") + msgID := c.Param("msgId") + + if !userCanAccessChannel(c, h.stores, channelID, userID) { + return + } + + // Verify message belongs to this channel and is not already deleted + var exists bool + _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` + SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL) + `), msgID, channelID).Scan(&exists) + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } + + if err := h.stores.Messages.Delete(c.Request.Context(), msgID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete message"}) + return + } + + // Broadcast to channel participants (exclude sender — they already removed it) + broadcastMessageDeleted(c.Request.Context(), h.hub, channelID, msgID, userID) + + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// broadcastMessageDeleted publishes a message.deleted event to all user +// participants in the channel except the sender. +func broadcastMessageDeleted(ctx context.Context, hub *events.Hub, channelID, msgID, senderID string) { + payload, _ := json.Marshal(map[string]any{ + "id": msgID, + "channel_id": channelID, + }) + evt := events.Event{ + Label: "message.deleted", + Payload: payload, + Ts: time.Now().UnixMilli(), + } + rows, err := database.DB.QueryContext(ctx, database.Q(` + SELECT participant_id FROM channel_participants + WHERE channel_id = $1 AND participant_type = 'user' + `), channelID) + if err != nil { + return + } + defer rows.Close() + for rows.Next() { + var pid string + if rows.Scan(&pid) == nil && pid != senderID { + hub.PublishToUser(pid, evt) + } + } +} diff --git a/server/handlers/notes.go b/server/handlers/notes.go index 747fe69..5f9875d 100644 --- a/server/handlers/notes.go +++ b/server/handlers/notes.go @@ -458,7 +458,7 @@ func (h *NoteHandler) ListFolders(c *gin.Context) { folders = append(folders, folderInfo{Path: f.Path, Count: f.Count}) } - c.JSON(http.StatusOK, gin.H{"folders": folders}) + c.JSON(http.StatusOK, gin.H{"data": folders}) } // ── Helpers ───────────────────────────────── diff --git a/server/handlers/package_registry.go b/server/handlers/package_registry.go index e80cdf6..964a302 100644 --- a/server/handlers/package_registry.go +++ b/server/handlers/package_registry.go @@ -101,7 +101,7 @@ func (h *RegistryHandler) BrowseRegistry(c *gin.Context) { url := h.getRegistryURL(c) if url == "" { c.JSON(http.StatusOK, gin.H{ - "packages": []RegistryEntry{}, + "data":[]RegistryEntry{}, "registry_url": "", }) return @@ -113,7 +113,7 @@ func (h *RegistryHandler) BrowseRegistry(c *gin.Context) { data := h.cacheData h.cacheMu.RUnlock() c.JSON(http.StatusOK, gin.H{ - "packages": data.Packages, + "data":data.Packages, "registry_url": url, }) return @@ -156,7 +156,7 @@ func (h *RegistryHandler) BrowseRegistry(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "packages": entries, + "data":entries, "registry_url": url, }) } diff --git a/server/handlers/packages.go b/server/handlers/packages.go index 08dbb4a..e4cd5d8 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -560,7 +560,7 @@ func (h *PackageHandler) UpdatePackageSettings(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"values": json.RawMessage(settingsJSON)}) + c.JSON(http.StatusOK, gin.H{"data": json.RawMessage(settingsJSON)}) } // validateSettingType checks if a value matches the expected setting type. diff --git a/server/handlers/participants.go b/server/handlers/participants.go index 062ab0d..65ae924 100644 --- a/server/handlers/participants.go +++ b/server/handlers/participants.go @@ -46,7 +46,7 @@ func (h *ParticipantHandler) List(c *gin.Context) { if participants == nil { participants = []models.ChannelParticipant{} } - c.JSON(http.StatusOK, gin.H{"participants": participants}) + c.JSON(http.StatusOK, gin.H{"data": participants}) } // ── Add ────────────────────────────────────── @@ -167,7 +167,7 @@ func (h *ParticipantHandler) Add(c *gin.Context) { // Return updated participant list participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID) - c.JSON(http.StatusCreated, gin.H{"participants": participants}) + c.JSON(http.StatusCreated, gin.H{"data": participants}) } // ── Update Role ───────────────────────────── @@ -220,7 +220,7 @@ func (h *ParticipantHandler) Update(c *gin.Context) { } participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID) - c.JSON(http.StatusOK, gin.H{"participants": participants}) + c.JSON(http.StatusOK, gin.H{"data": participants}) } // ── Remove ────────────────────────────────── @@ -289,7 +289,7 @@ func (h *ParticipantHandler) Remove(c *gin.Context) { } participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID) - c.JSON(http.StatusOK, gin.H{"participants": participants}) + c.JSON(http.StatusOK, gin.H{"data": participants}) } // ── Helpers ────────────────────────────────── diff --git a/server/handlers/presence.go b/server/handlers/presence.go index eb12ce9..06cbe31 100644 --- a/server/handlers/presence.go +++ b/server/handlers/presence.go @@ -80,5 +80,5 @@ func (h *PresenceHandler) SearchUsers(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"}) return } - c.JSON(http.StatusOK, gin.H{"users": results}) + c.JSON(http.StatusOK, gin.H{"data": results}) } diff --git a/server/handlers/profile_bootstrap.go b/server/handlers/profile_bootstrap.go new file mode 100644 index 0000000..3f7c467 --- /dev/null +++ b/server/handlers/profile_bootstrap.go @@ -0,0 +1,117 @@ +package handlers + +// profile_bootstrap.go — Single-call boot payload for the SDK. +// +// GET /api/v1/profile/bootstrap +// +// Collapses what previously required 3-4 sequential requests +// (profile, permissions, teams/mine, settings) into one call. +// The SDK calls this at startup and on token refresh. +// +// v0.37.15 + +import ( + "net/http" + "sort" + + "github.com/gin-gonic/gin" + + "chat-switchboard/auth" + "chat-switchboard/store" +) + +// ProfileBootstrapHandler serves the combined boot payload. +type ProfileBootstrapHandler struct { + stores store.Stores +} + +func NewProfileBootstrapHandler(s store.Stores) *ProfileBootstrapHandler { + return &ProfileBootstrapHandler{stores: s} +} + +// GetBootstrap returns everything the shell needs at startup. +// GET /api/v1/profile/bootstrap +func (h *ProfileBootstrapHandler) GetBootstrap(c *gin.Context) { + userID := getUserID(c) + role, _ := c.Get("role") + ctx := c.Request.Context() + + // ── User profile ──────────────────────── + user, err := h.stores.Users.GetByID(ctx, userID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + userPayload := gin.H{ + "id": user.ID, + "username": user.Username, + "display_name": user.DisplayName, + "email": user.Email, + "role": user.Role, + } + if user.AvatarURL != "" { + userPayload["avatar"] = user.AvatarURL + } + + // ── Permissions ───────────────────────── + var permList []string + if role == "admin" { + permList = make([]string, len(auth.AllPermissions)) + copy(permList, auth.AllPermissions) + } else { + perms, err := auth.ResolvePermissions(ctx, h.stores, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"}) + return + } + permList = make([]string, 0, len(perms)) + for p := range perms { + permList = append(permList, p) + } + } + sort.Strings(permList) + + // ── Groups ────────────────────────────── + groupIDs, _ := h.stores.Groups.GetUserGroupIDs(ctx, userID) + if groupIDs == nil { + groupIDs = []string{} + } + groupIDs = append(groupIDs, auth.EveryoneGroupID) + + // ── Teams ─────────────────────────────── + teams, _ := h.stores.Teams.ListForUser(ctx, userID) + teamData := make([]gin.H, 0, len(teams)) + for _, t := range teams { + teamData = append(teamData, gin.H{ + "id": t.ID, + "name": t.Name, + "my_role": t.MyRole, + }) + } + + // ── Policies ──────────────────────────── + policies := make(map[string]bool) + if ps := h.stores.Policies; ps != nil { + policies["allow_user_byok"], _ = ps.GetBool(ctx, "allow_user_byok") + policies["allow_user_personas"], _ = ps.GetBool(ctx, "allow_user_personas") + policies["allow_raw_model_access"], _ = ps.GetBool(ctx, "allow_raw_model_access") + policies["kb_direct_access"], _ = ps.GetBool(ctx, "kb_direct_access") + } + + // ── Settings ──────────────────────────── + settings := make(map[string]interface{}) + if user.Settings != nil { + settings = user.Settings + } + + // ── Response ──────────────────────────── + c.JSON(http.StatusOK, gin.H{ + "user": userPayload, + "permissions": permList, + "groups": groupIDs, + "teams": teamData, + "policies": policies, + "settings": settings, + }) +} diff --git a/server/handlers/roles.go b/server/handlers/roles.go index 3e06a98..500434a 100644 --- a/server/handlers/roles.go +++ b/server/handlers/roles.go @@ -161,7 +161,7 @@ func (h *RolesHandler) ListTeamRoles(c *gin.Context) { } } - c.JSON(http.StatusOK, gin.H{"data": roleOverrides}) + c.JSON(http.StatusOK, roleOverrides) } // UpdateTeamRole sets a team role override. diff --git a/server/handlers/team_providers.go b/server/handlers/team_providers.go index d00ffc4..d0e69a0 100644 --- a/server/handlers/team_providers.go +++ b/server/handlers/team_providers.go @@ -326,7 +326,7 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) { out = append(out, modelInfo{ID: m.ID, Type: m.Type, Capabilities: caps}) } - c.JSON(http.StatusOK, gin.H{"models": out, "provider": cfg.Name}) + c.JSON(http.StatusOK, gin.H{"data": out, "provider": cfg.Name}) } // parseJSONBConfig parses a JSONB text string into a map. diff --git a/server/handlers/teams.go b/server/handlers/teams.go index a29981b..b0c3e6b 100644 --- a/server/handlers/teams.go +++ b/server/handlers/teams.go @@ -428,7 +428,7 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) { } } - c.JSON(http.StatusOK, gin.H{"models": result}) + c.JSON(http.StatusOK, gin.H{"data": result}) } // ── Helpers ───────────────────────────────── diff --git a/server/main.go b/server/main.go index bcbcfb9..d63e1e1 100644 --- a/server/main.go +++ b/server/main.go @@ -35,6 +35,7 @@ import ( "chat-switchboard/notifications" "chat-switchboard/pages" "chat-switchboard/providers" + "chat-switchboard/retention" "chat-switchboard/roles" "chat-switchboard/routing" "chat-switchboard/scheduler" @@ -234,6 +235,11 @@ func main() { }() } + // v0.37.14: Channel retention scanner — purges archived channels past their TTL + retScanner := retention.NewScanner(stores, objStore, retention.ScannerConfig{}) + retScanner.Start() + defer retScanner.Stop() + // v0.27.2: Task scheduler startup deferred to after hub/notification init — see below. // Bootstrap admin from env (K8s secret) — upserts on every restart @@ -751,6 +757,7 @@ func main() { protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage) protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate) protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings) + protected.DELETE("/channels/:id/messages/:msgId", msgs.DeleteMessage) // Chat Completions comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder) @@ -824,6 +831,10 @@ func main() { permH := handlers.NewProfilePermissionsHandler(stores) protected.GET("/profile/permissions", permH.GetMyPermissions) + // Boot payload (v0.37.15) — single-call SDK bootstrap + bootH := handlers.NewProfileBootstrapHandler(stores) + protected.GET("/profile/bootstrap", bootH.GetBootstrap) + // Usage (personal) usage := handlers.NewUsageHandler(stores) protected.GET("/usage", usage.PersonalUsage) diff --git a/server/models/models.go b/server/models/models.go index c3103d4..a7598bb 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -328,8 +328,9 @@ type Channel struct { Model string `json:"model,omitempty" db:"model"` SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"` ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"` - IsArchived bool `json:"is_archived" db:"is_archived"` - IsPinned bool `json:"is_pinned" db:"is_pinned"` + IsArchived bool `json:"is_archived" db:"is_archived"` + IsPinned bool `json:"is_pinned" db:"is_pinned"` + PurgeAfter *time.Time `json:"purge_after,omitempty" db:"purge_after"` FolderID *string `json:"folder_id,omitempty" db:"folder_id"` TeamID *string `json:"team_id,omitempty" db:"team_id"` ProjectID *string `json:"project_id,omitempty" db:"project_id"` diff --git a/server/pages/loaders.go b/server/pages/loaders.go index 3173924..3a02ee7 100644 --- a/server/pages/loaders.go +++ b/server/pages/loaders.go @@ -149,7 +149,7 @@ func (e *Engine) adminLoader(c *gin.Context, s store.Stores) (any, error) { ctx := context.Background() section := c.Param("section") if section == "" { - section = "overview" + section = "users" } data := &AdminPageData{ diff --git a/server/pages/pages.go b/server/pages/pages.go index 27623d1..debf789 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -68,9 +68,24 @@ type BannerConfig struct { Visible bool `json:"visible"` } +// MessageConfig holds dismissible message bar settings. +type MessageConfig struct { + Text string `json:"text"` + Variant string `json:"variant"` // info, warn, error, success + Visible bool `json:"visible"` +} + +// FooterConfig holds optional footer bar settings. +type FooterConfig struct { + Text string `json:"text"` + Visible bool `json:"visible"` +} + // PageData is passed to every template render. type PageData struct { Banner BannerConfig + Message MessageConfig + Footer FooterConfig Surface string // active surface ID Section string // sub-section (for admin pages) CSPNonce string @@ -300,6 +315,8 @@ func (e *Engine) Render(c *gin.Context, name string, data PageData) { data.Environment = e.cfg.Environment data.CSPNonce = generateNonce() data.Banner = e.loadBanner() + data.Message = e.loadMessage() + data.Footer = e.loadFooter() // v0.22.7: Default theme if not set if data.Theme == "" { @@ -336,7 +353,7 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc { section := c.Param("section") if section == "" && surfaceID == "admin" { - section = "overview" + section = "users" } if section == "" && surfaceID == "settings" { section = "general" @@ -837,6 +854,50 @@ func (e *Engine) loadBanner() BannerConfig { return b } +// loadMessage reads dismissible message bar config from global settings. +func (e *Engine) loadMessage() MessageConfig { + if e.stores.GlobalConfig == nil { + return MessageConfig{} + } + raw, err := e.stores.GlobalConfig.Get(context.Background(), "message") + if err != nil || raw == nil { + return MessageConfig{} + } + m := MessageConfig{} + if v, ok := raw["enabled"].(bool); ok { + m.Visible = v + } + if v, ok := raw["text"].(string); ok { + m.Text = v + } + if v, ok := raw["variant"].(string); ok { + m.Variant = v + } + if m.Variant == "" { + m.Variant = "info" + } + return m +} + +// loadFooter reads optional footer bar config from global settings. +func (e *Engine) loadFooter() FooterConfig { + if e.stores.GlobalConfig == nil { + return FooterConfig{} + } + raw, err := e.stores.GlobalConfig.Get(context.Background(), "footer") + if err != nil || raw == nil { + return FooterConfig{} + } + f := FooterConfig{} + if v, ok := raw["enabled"].(bool); ok { + f.Visible = v + } + if v, ok := raw["text"].(string); ok { + f.Text = v + } + return f +} + // loadBranding reads instance branding from global settings. func (e *Engine) loadBranding() (name, logoURL, tagline string) { name = "Chat Switchboard" // default diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html index faee5ee..c8d0e22 100644 --- a/server/pages/templates/base.html +++ b/server/pages/templates/base.html @@ -3,7 +3,7 @@ - + {{block "title" .}}Chat Switchboard{{end}} @@ -24,6 +24,7 @@ + {{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}} {{if eq .Surface "notes"}}{{template "css-notes" .}}{{end}} {{/* v0.27.0: Extension surface CSS — loaded from /surfaces/{id}/css/main.css */}} @@ -51,6 +52,23 @@ document.documentElement.setAttribute('data-theme', resolved); var meta = document.getElementById('metaThemeColor'); if (meta) meta.content = resolved === 'light' ? '#f7f7fa' : '#0e0e10'; + var fav = document.querySelector('link[rel="icon"][type="image/svg+xml"]'); + if (fav) fav.href = fav.href.replace(/favicon(-light)?\.svg/, resolved === 'light' ? 'favicon-light.svg' : 'favicon.svg'); + } catch(e) {} + })(); + // Early appearance — apply scale + msg font from localStorage on all surfaces. + // Scale uses transform on .surface-inner so shell stays fixed, content fills viewport. + (function() { + try { + var p = JSON.parse(localStorage.getItem('cs-appearance') || '{}'); + if (p.scale && p.scale !== 100) { + var s = p.scale / 100; + document.addEventListener('DOMContentLoaded', function() { + var el = document.getElementById('surfaceInner'); + if (el) { el.style.transform = 'scale('+s+')'; el.style.transformOrigin = 'top left'; el.style.width = (100/s)+'%'; el.style.height = (100/s)+'%'; } + }); + } + if (p.msgFont && p.msgFont !== 14) document.documentElement.style.setProperty('--msg-font', p.msgFont + 'px'); } catch(e) {} })(); @@ -62,6 +80,15 @@ {{end}} + {{if .Message.Visible}} +
+
+ {{.Message.Text}} + +
+
+ {{end}} +
{{if eq .Surface "chat"}}{{template "surface-chat" .}} @@ -75,6 +102,10 @@
+ {{if .Footer.Visible}} + + {{end}} + {{if .Banner.Visible}}