Changeset 0.28.0.1 (#173)
This commit is contained in:
2621
docs/ICD-API.md
2621
docs/ICD-API.md
File diff suppressed because it is too large
Load Diff
@@ -1,265 +0,0 @@
|
|||||||
# ICD Audit: API Contract vs ROADMAP / ARCHITECTURE
|
|
||||||
|
|
||||||
**Date:** 2026-03-04 | **Baseline:** v0.23.0
|
|
||||||
**Scope:** Cross-reference ICD-API.md against ROADMAP.md, ARCHITECTURE.md, and live codebase.
|
|
||||||
**Status:** §2 (naming) fully resolved — preset→persona rename complete across all JS, templates, CSS, Go handlers, and ICD. §3 (cleanup) resolved. §4.1 (chat_id alias) resolved. §4.2 (legacy list messages) open. ICD updated to target state including multi-participant channels.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. ARCHITECTURE.md — Staleness Report
|
|
||||||
|
|
||||||
ARCHITECTURE.md is stamped **v0.20** — six minor versions behind. The following sections are materially stale:
|
|
||||||
|
|
||||||
### 1.1 Frontend File Structure (§ Frontend Architecture)
|
|
||||||
|
|
||||||
ARCHITECTURE lists 19 JS files. Codebase has **34**. Fifteen files are undocumented:
|
|
||||||
|
|
||||||
| Missing from ARCHITECTURE | Added In | Purpose |
|
|
||||||
|---------------------------|----------|---------|
|
|
||||||
| `admin-scaffold.js` | v0.22.7 | Admin section DOM scaffolding |
|
|
||||||
| `channel-models.js` | v0.20.0 | Multi-model channel roster UI |
|
|
||||||
| `chat-pane.js` | v0.22.7 | Reusable ChatPane component |
|
|
||||||
| `editor-mode.js` | v0.21.5 | Editor surface (listed but not in file tree) |
|
|
||||||
| `extensions.js` | v0.11.0 | Extension loader |
|
|
||||||
| `notification-prefs.js` | v0.20.0 | Notification preference UI |
|
|
||||||
| `notifications.js` | v0.20.0 | Bell dropdown + badge |
|
|
||||||
| `pages.js` | v0.22.5 | Page-level auth + routing |
|
|
||||||
| `pages-splash.js` | v0.22.7 | Login/register surface |
|
|
||||||
| `panels.js` | v0.18.1 | Side panel architecture |
|
|
||||||
| `persona-kb.js` | v0.17.0 | Persona-KB binding UI |
|
|
||||||
| `repl.js` | v0.21.3 | REPL surface |
|
|
||||||
| `surface-nav.js` | v0.22.8 | Surface navigation overrides |
|
|
||||||
| `tools-toggle.js` | v0.13.1 | Per-tool enable/disable UI |
|
|
||||||
| `ui-format.js` | v0.22.7 | Message formatting helpers |
|
|
||||||
| `ui-primitives.js` | v0.10.5 | Shared render primitives |
|
|
||||||
| `ui-primitives-additions.js` | v0.22.7 | Toast, badge, icon extensions |
|
|
||||||
|
|
||||||
ARCHITECTURE also lists `knowledge.js` — **renamed** to `knowledge-ui.js`.
|
|
||||||
|
|
||||||
### 1.2 SPA Shell References
|
|
||||||
|
|
||||||
ARCHITECTURE §Deployment Modes still says:
|
|
||||||
|
|
||||||
> "The frontend entrypoint (`docker-entrypoint-fe.sh`) handles `BASE_PATH` injection into `index.html`"
|
|
||||||
|
|
||||||
And the file tree lists:
|
|
||||||
|
|
||||||
> `├── index.html # Single-page app shell`
|
|
||||||
|
|
||||||
Both are wrong since v0.22.6. `index.html` is now a 16-line redirect stub. The real entry points are Go-rendered surface templates. The entrypoint now generates `proxy_pass` blocks to the backend for surface routes.
|
|
||||||
|
|
||||||
### 1.3 Deployment Diagram
|
|
||||||
|
|
||||||
The split-deployment description is functionally correct but the mechanism is wrong. Frontend no longer serves an SPA — it reverse-proxies page routes to the backend, which renders Go templates. The diagram should show the backend serving both API and HTML.
|
|
||||||
|
|
||||||
### 1.4 Backward Compatibility Section
|
|
||||||
|
|
||||||
The section says:
|
|
||||||
|
|
||||||
> "JSON field rename: `api_config_id` → `provider_config_id`... Frontend updated to match."
|
|
||||||
|
|
||||||
This is now ancient history (v0.9). The rename is complete everywhere. This section should either be removed or reduced to a single sentence noting historical context.
|
|
||||||
|
|
||||||
### 1.5 Missing Sections
|
|
||||||
|
|
||||||
ARCHITECTURE has **no documentation** for:
|
|
||||||
|
|
||||||
- Go template engine (`server/pages/`)
|
|
||||||
- Surface architecture (5 surfaces: chat, editor, notes, admin, settings)
|
|
||||||
- Template components (`model-select`, `team-select`, `file-upload`, `chat-pane`)
|
|
||||||
- Page data loaders (`loaders.go`)
|
|
||||||
- Provider Extensions / hook system (v0.22.1)
|
|
||||||
- Routing policies (v0.22.2)
|
|
||||||
- Export system (v0.22.4)
|
|
||||||
- Git integration (v0.21.4)
|
|
||||||
- The `treepath/` package (cursor, path, siblings)
|
|
||||||
|
|
||||||
**Recommendation:** Bump to v0.22 and rewrite §Frontend Architecture, §Deployment Modes, and §Backward Compatibility. Add §Template Engine, §Surface Architecture, §Provider Extensions sections.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. ROADMAP.md — Housekeeping
|
|
||||||
|
|
||||||
### 2.1 Naming Inconsistency: Presets vs Personas
|
|
||||||
|
|
||||||
The ROADMAP uses "Persona" consistently. The API routes use `/personas`. The Go handler type is `PersonaHandler`. The JSON response sends **both** `"personas"` and `"presets"` keys (backward compat). The frontend reads the `presets` key exclusively.
|
|
||||||
|
|
||||||
This is a naming barnacle that bleeds into every layer:
|
|
||||||
|
|
||||||
| Layer | Name Used |
|
|
||||||
|-------|-----------|
|
|
||||||
| ROADMAP / ARCHITECTURE | Persona |
|
|
||||||
| Go types | `Persona`, `PersonaPatch` |
|
|
||||||
| Go handler | `PersonaHandler` |
|
|
||||||
| API routes | `/personas`, `/admin/personas` |
|
|
||||||
| JSON response keys | `"personas"` AND `"presets"` (dual) |
|
|
||||||
| Frontend JS | `presets` (reads `data.presets`) |
|
|
||||||
| UI labels | Mixed ("Preset" in some places, "Persona" in others) |
|
|
||||||
|
|
||||||
**Recommendation:** Pick one. The domain concept is "Persona" (it carries identity, system prompt, model, KB bindings — it's not just a preset). Two options:
|
|
||||||
|
|
||||||
- **Option A — Route rename**: `/personas` → `/personas`. Update FE. Remove dual JSON keys. This is a clean break and pre-1.0 is the time to do it.
|
|
||||||
- **Option B — Accept "presets" everywhere**: Rename Go types to `Preset`, update docs. Simpler but loses semantic precision.
|
|
||||||
|
|
||||||
### 2.2 `chat_id` Backward Compat Alias
|
|
||||||
|
|
||||||
`completionRequest` has:
|
|
||||||
|
|
||||||
```go
|
|
||||||
ChatID string `json:"chat_id"` // deprecated alias — maps to channel_id
|
|
||||||
```
|
|
||||||
|
|
||||||
The frontend doesn't send `chat_id` anymore. This is dead code in the handler that adds 3 lines of aliasing logic. Safe to remove.
|
|
||||||
|
|
||||||
### 2.3 Go Field Name Barnacle: `APIConfigID`
|
|
||||||
|
|
||||||
Throughout `channels.go` and `completion.go`, the Go struct field is named `APIConfigID` with json tag `"provider_config_id"`. The JSON tag is correct, so this is invisible to the API — but it's a source of confusion for anyone reading the Go code. Rename the Go field to `ProviderConfigID` for consistency.
|
|
||||||
|
|
||||||
### 2.4 Dual JSON Keys on Persona List
|
|
||||||
|
|
||||||
Three `PersonaHandler` methods return:
|
|
||||||
|
|
||||||
```go
|
|
||||||
gin.H{"personas": personas, "presets": personas}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `"presets"` key exists solely for backward compat that was needed in v0.9. The FE only reads `"presets"`. If we go with Option A (§2.1), remove the `"presets"` key. If Option B, remove the `"personas"` key. Either way, stop sending both.
|
|
||||||
|
|
||||||
### 2.5 Route Alias: `/models` → `/models/enabled`
|
|
||||||
|
|
||||||
`main.go` has:
|
|
||||||
|
|
||||||
```go
|
|
||||||
protected.GET("/models/enabled", modelH.ListEnabledModels)
|
|
||||||
protected.GET("/models", modelH.ListEnabledModels) // alias
|
|
||||||
```
|
|
||||||
|
|
||||||
Plus the admin has its own `/admin/models`. Having `/models` as an alias for `/models/enabled` is confusing when `/admin/models` is a completely different thing. The ICD documents both, but one should go away. The FE uses `listEnabledModels()` → `/models/enabled`. The alias is unused.
|
|
||||||
|
|
||||||
### 2.6 ROADMAP v0.22.8 — Status vs Reality
|
|
||||||
|
|
||||||
The ROADMAP shows v0.22.8 as the next incomplete version with checkboxes for ChatPane Bridge, Theme Integration, Admin Surface, and Settings Surface. The last two sessions partially implemented some of these (admin scaffold, surface-nav, prototype overrides) but things broke and were rolled back/lost. The ROADMAP doesn't reflect the partial state — it still shows all items as unchecked.
|
|
||||||
|
|
||||||
**Recommendation:** After the ICD is finalized and the FE rebuild begins properly, update v0.22.8 checkboxes to reflect actual completion. Consider splitting v0.22.8 into smaller increments since it's become a catch-all.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. ICD Gaps (things the backend implements but ICD doesn't fully cover)
|
|
||||||
|
|
||||||
### 3.1 Admin Models — Specific Endpoints
|
|
||||||
|
|
||||||
The ICD §24.5 lists admin model routes but doesn't document request/response shapes for:
|
|
||||||
|
|
||||||
- `POST /admin/models/fetch` — triggers provider sync, returns updated catalog
|
|
||||||
- `PUT /admin/models/bulk` — bulk visibility updates (request shape)
|
|
||||||
- `PUT /admin/models/:id` — single model update (what fields?)
|
|
||||||
|
|
||||||
**Fix:** Extract shapes from `admin.go` handler methods and add to ICD.
|
|
||||||
|
|
||||||
### 3.2 Admin Stats Response Shape
|
|
||||||
|
|
||||||
ICD §24.3 says "returns aggregate counts" but doesn't specify the shape. Should document:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"users": 42,
|
|
||||||
"channels": 156,
|
|
||||||
"messages": 12847,
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 Admin Global Config — configWithKey Shape
|
|
||||||
|
|
||||||
ICD §24.4 says admin configs include `has_key` flag but doesn't show the full shape. The actual `configWithKey` response differs from the user-facing `safeConfig` — it includes `config`, `headers`, `settings` objects.
|
|
||||||
|
|
||||||
### 3.4 Team Provider Models
|
|
||||||
|
|
||||||
`GET /teams/:teamId/providers/:id/models` is in `main.go` but not clearly called out in the ICD §18.2 team admin section.
|
|
||||||
|
|
||||||
### 3.5 Team Audit Endpoints
|
|
||||||
|
|
||||||
`GET /teams/:teamId/audit` and `GET /teams/:teamId/audit/actions` are registered but not in the ICD team section.
|
|
||||||
|
|
||||||
### 3.6 Knowledge Base — Missing Detail
|
|
||||||
|
|
||||||
ICD §14 doesn't document:
|
|
||||||
|
|
||||||
- `GET /channels/:id/knowledge-bases` response shape (array of `ChannelKB`)
|
|
||||||
- `PUT /channels/:id/knowledge-bases` request shape
|
|
||||||
- `GET /knowledge-bases-discoverable` vs `GET /knowledge-bases` distinction
|
|
||||||
- KB document status progression: `pending → chunking → embedding → ready | error`
|
|
||||||
|
|
||||||
### 3.7 Export Endpoint Shape
|
|
||||||
|
|
||||||
ICD §21.2 has the right route but should note that `pandoc` must be available in the container. Also missing: supported format list beyond pdf/docx (the handler may accept more).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. ICD Barnacles (things in the ICD that should be cleaned up)
|
|
||||||
|
|
||||||
### 4.1 `chat_id` Alias
|
|
||||||
|
|
||||||
The ICD §5.1 notes `chat_id` as a deprecated alias in the completion request. Once the dead code is removed from the handler, remove from ICD.
|
|
||||||
|
|
||||||
### 4.2 Legacy ListMessages
|
|
||||||
|
|
||||||
ICD §4.2 documents `GET /channels/:id/messages` as "Legacy/Debug." If it's truly not for frontend use, consider either removing the route or moving it to the admin namespace. Currently it's in the protected (user) namespace, which means any user can dump all messages (including branches) for their channels.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. ROADMAP/ARCHITECTURE Reorganization Suggestions
|
|
||||||
|
|
||||||
### 5.1 ARCHITECTURE Should Reference the ICD
|
|
||||||
|
|
||||||
Instead of maintaining API route tables in multiple places, ARCHITECTURE should say "See ICD-API.md for the complete endpoint reference" and focus on:
|
|
||||||
|
|
||||||
- Design principles, scope model, resolution chains (what it does well today)
|
|
||||||
- Package structure (update to v0.22)
|
|
||||||
- Frontend architecture (rewrite for surfaces/templates)
|
|
||||||
- Security model (mostly current)
|
|
||||||
- New: Template engine, provider extensions, routing
|
|
||||||
|
|
||||||
### 5.2 ROADMAP Completed Versions — Collapse
|
|
||||||
|
|
||||||
The ROADMAP has 900+ lines of completed release details (v0.9 through v0.22.7). These are duplicated in CHANGELOG.md. The ROADMAP should collapse completed versions to one-liners (as it does for v0.9.x through v0.16.0) and push detailed history to CHANGELOG.
|
|
||||||
|
|
||||||
Currently v0.17.0 through v0.22.7 have full checkbox-level detail in the ROADMAP — that's useful while in-progress but noise once shipped. Collapse them.
|
|
||||||
|
|
||||||
### 5.3 Three-Document Model
|
|
||||||
|
|
||||||
Post-audit, the documentation should be:
|
|
||||||
|
|
||||||
| Document | Audience | Content |
|
|
||||||
|----------|----------|---------|
|
|
||||||
| `ARCHITECTURE.md` | Developers | Design principles, package structure, data model, resolution chains, security, deployment. **No API details.** |
|
|
||||||
| `ICD-API.md` | FE developers, integrators | Complete API contract: routes, shapes, auth, streaming, WebSocket. |
|
|
||||||
| `ROADMAP.md` | Stakeholders, contributors | Version plan, dependency graph, future features. Completed versions collapsed to one-liners with CHANGELOG refs. |
|
|
||||||
|
|
||||||
Future additions:
|
|
||||||
|
|
||||||
| Document | Audience | Content |
|
|
||||||
|----------|----------|---------|
|
|
||||||
| `ICD-SURFACE.md` | FE developers | Surface contract: templates, PageData, DOM IDs, JS globals, lifecycle. |
|
|
||||||
| `ICD-EXTENSION.md` | Extension authors | Extension contract: manifest, tiers, hooks, tool bridge, asset serving. |
|
|
||||||
|
|
||||||
### 5.4 ROADMAP Dependency Graph — Update
|
|
||||||
|
|
||||||
The ASCII dependency graph stops at v0.22.7. The v0.22.8 → v0.23.0 → v0.24.0 path should be added.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Cleanup Punch List
|
|
||||||
|
|
||||||
Priority-ordered actions that could all happen in a single commit:
|
|
||||||
|
|
||||||
| # | Action | Risk | Lines Changed |
|
|
||||||
|---|--------|------|---------------|
|
|
||||||
| 1 | Remove `chat_id` alias from `completionRequest` | None — FE doesn't use it | ~5 |
|
|
||||||
| 2 | Rename Go field `APIConfigID` → `ProviderConfigID` in channels.go + completion.go | None — json tags unchanged | ~30 |
|
|
||||||
| 3 | Pick persona vs preset naming, remove dual JSON keys | Low — FE reads one key | ~10 |
|
|
||||||
| 4 | Remove `/models` alias (keep `/models/enabled` only) | Low — check FE uses | ~1 |
|
|
||||||
| 5 | Bump ARCHITECTURE.md to v0.22, rewrite §Frontend | Medium — documentation | ~100 |
|
|
||||||
| 6 | Collapse ROADMAP completed versions (v0.17–v0.22.7) to one-liners | None — CHANGELOG has detail | ~600 removed |
|
|
||||||
| 7 | Add missing ICD shapes (§3 above) | None — documentation | ~80 added |
|
|
||||||
| 8 | Update ICD to remove chat_id and document any cleanup from #1–#4 | None | ~10 |
|
|
||||||
155
docs/ICD/README.md
Normal file
155
docs/ICD/README.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# ICD-API — Chat Switchboard Backend API Contract
|
||||||
|
|
||||||
|
**Version:** 0.28.0
|
||||||
|
**Updated:** 2026-03-11
|
||||||
|
**Audience:** Frontend developers, integrators, API consumers
|
||||||
|
|
||||||
|
> **Organization principle:** Each file is one domain — the complete story.
|
||||||
|
> Every endpoint that touches Knowledge Bases is in `knowledge.md`, every
|
||||||
|
> endpoint that touches Personas is in `personas.md`, etc.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Domain | Endpoints |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| [auth.md](auth.md) | Auth — login, register, refresh, OIDC, mTLS | 6 |
|
||||||
|
| [channels.md](channels.md) | Channels, messages, completions, participants, folders, presence | ~35 |
|
||||||
|
| [personas.md](personas.md) | Personas, persona groups, avatars, KB bindings, tool grants | ~20 |
|
||||||
|
| [knowledge.md](knowledge.md) | Knowledge bases, documents, search, channel/persona bindings | ~12 |
|
||||||
|
| [notes.md](notes.md) | Notes, wikilinks, graph, search, folders | ~10 |
|
||||||
|
| [workspaces.md](workspaces.md) | Workspaces, file ops, git, credentials | ~20 |
|
||||||
|
| [projects.md](projects.md) | Projects, channel/KB/note associations | ~14 |
|
||||||
|
| [memory.md](memory.md) | Memory extraction, review, admin | 7 |
|
||||||
|
| [providers.md](providers.md) | Provider configs, health, capabilities, routing policies | ~25 |
|
||||||
|
| [models.md](models.md) | Enabled models, user preferences | 4 |
|
||||||
|
| [notifications.md](notifications.md) | Notifications, preferences | 7 |
|
||||||
|
| [extensions.md](extensions.md) | Extensions, user settings, admin | 8 |
|
||||||
|
| [profile.md](profile.md) | User profile, avatar, password, app settings | 7 |
|
||||||
|
| [teams.md](teams.md) | Teams, members, groups, permissions, grants | ~25 |
|
||||||
|
| [admin.md](admin.md) | Platform admin — users, settings, stats, audit, usage, vault | ~25 |
|
||||||
|
| [utility.md](utility.md) | Health, export, files, storage | ~12 |
|
||||||
|
| [workflows.md](workflows.md) | Workflow definitions, stages, instances, assignments, entry | ~20 |
|
||||||
|
| [tasks.md](tasks.md) | Task definitions, runs, scheduler, team tasks | ~14 |
|
||||||
|
| [surfaces.md](surfaces.md) | Surface registry, extension surfaces | 6 |
|
||||||
|
| [websocket.md](websocket.md) | WebSocket protocol, events, rooms | — |
|
||||||
|
| [enums.md](enums.md) | All enum values, policies | — |
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
{scheme}://{host}{BASE_PATH}/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
`BASE_PATH` is empty by default. In path-routed K8s deployments (e.g.
|
||||||
|
`/staging/`), all routes shift accordingly. The frontend reads `BASE_PATH`
|
||||||
|
from a `<meta>` tag injected by the Go template engine.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
JWT bearer tokens. Every request to a `protected` or `admin` route requires:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer {access_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access token:** HS256 JWT, 15-minute TTL. Claims: `user_id`, `email`,
|
||||||
|
`role`, `exp`, `iat`, `jti`.
|
||||||
|
|
||||||
|
**Refresh token:** Opaque, DB-stored, 7-day TTL. Used to obtain new
|
||||||
|
access tokens without re-login.
|
||||||
|
|
||||||
|
**WebSocket fallback:** `?token={access_token}` query parameter when
|
||||||
|
the `Authorization` header isn't available.
|
||||||
|
|
||||||
|
**Cookie sync:** On every token save/refresh, the frontend writes
|
||||||
|
`sb_token` as a cookie. Go template page routes read this cookie via
|
||||||
|
`AuthOrRedirect` middleware for server-rendered surfaces.
|
||||||
|
|
||||||
|
### Auth Modes
|
||||||
|
|
||||||
|
| Mode | `AUTH_MODE` | How it works |
|
||||||
|
|------|------------|--------------|
|
||||||
|
| Builtin | `builtin` (default) | Username/password, bcrypt, JWT |
|
||||||
|
| mTLS | `mtls` | Reverse proxy cert headers, auto-provision |
|
||||||
|
| OIDC | `oidc` | Authorization code flow (Keycloak etc.) |
|
||||||
|
|
||||||
|
All three modes issue the same JWT after authentication. Downstream
|
||||||
|
middleware is auth-mode-agnostic.
|
||||||
|
|
||||||
|
### Authorization Tiers
|
||||||
|
|
||||||
|
| Tier | Middleware | Who |
|
||||||
|
|------|-----------|-----|
|
||||||
|
| Public | none | Anyone (health, login, register, public settings) |
|
||||||
|
| Session | `AuthOrSession()` | JWT user OR anonymous session cookie (workflow visitors) |
|
||||||
|
| Authenticated | `Auth()` | Any logged-in user |
|
||||||
|
| Permission | `RequirePermission(perm)` | User whose groups grant `perm` |
|
||||||
|
| Team Admin | `RequireTeamAdmin()` | Admin of the specific team |
|
||||||
|
| Platform Admin | `RequireAdmin()` | Users with `role = "admin"` |
|
||||||
|
|
||||||
|
### Error Envelope
|
||||||
|
|
||||||
|
All errors return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "human-readable message" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Standard HTTP status codes: 400 (bad request), 401 (not authenticated),
|
||||||
|
403 (not authorized), 404 (not found), 409 (conflict), 500 (server error),
|
||||||
|
502 (upstream provider failure).
|
||||||
|
|
||||||
|
### Pagination Envelope
|
||||||
|
|
||||||
|
Endpoints that paginate return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [...],
|
||||||
|
"page": 1,
|
||||||
|
"per_page": 50,
|
||||||
|
"total": 142,
|
||||||
|
"total_pages": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Query params: `?page=1&per_page=50`. Not all list endpoints paginate —
|
||||||
|
some return `{ "data": [...] }` or a bare array in a named key.
|
||||||
|
|
||||||
|
### ID Format
|
||||||
|
|
||||||
|
All resource IDs are UUIDv4 strings, generated application-side via
|
||||||
|
`store.NewID()` (Go `uuid.New()`). This ensures compatibility across
|
||||||
|
both Postgres and SQLite backends.
|
||||||
|
|
||||||
|
### Timestamps
|
||||||
|
|
||||||
|
ISO 8601 with timezone: `"2025-06-15T14:30:00Z"`. Stored as
|
||||||
|
`TIMESTAMPTZ` (Postgres) or `TEXT` (SQLite).
|
||||||
|
|
||||||
|
## Page Routes (Non-API)
|
||||||
|
|
||||||
|
Server-rendered Go template surfaces. Not REST endpoints — return HTML.
|
||||||
|
|
||||||
|
| Route | Surface | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `/login` | Login | Standalone login/register page |
|
||||||
|
| `/` | Chat | Main chat interface |
|
||||||
|
| `/chat/:chatID` | Chat | Chat with specific channel loaded |
|
||||||
|
| `/editor` | Editor | Workspace file editor |
|
||||||
|
| `/editor/:wsId` | Editor | Editor with specific workspace |
|
||||||
|
| `/notes` | Notes | Notes interface |
|
||||||
|
| `/notes/:noteId` | Notes | Notes with specific note loaded |
|
||||||
|
| `/admin` | Admin | Platform administration |
|
||||||
|
| `/admin/:section` | Admin | Admin with specific section |
|
||||||
|
| `/settings` | Settings | User settings |
|
||||||
|
| `/settings/:section` | Settings | Settings with specific section |
|
||||||
|
| `/w/:id` | Workflow Landing | Branded workflow entry page |
|
||||||
|
| `/w/:id/:slug` | Workflow Landing | Slug-suffixed workflow entry |
|
||||||
|
| `/s/:slug` | Extension Surface | Dynamic surface from registry |
|
||||||
|
|
||||||
|
All page routes (except `/login`, `/w/`) require authentication via
|
||||||
|
`AuthOrRedirect` middleware. Admin routes additionally require
|
||||||
|
`RequireAdminPage()`. Workflow landing pages use `AuthOrSession`.
|
||||||
198
docs/ICD/admin.md
Normal file
198
docs/ICD/admin.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# Platform Administration
|
||||||
|
|
||||||
|
Cross-cutting admin operations that don't belong to a specific domain.
|
||||||
|
|
||||||
|
### User Management
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/users → { "users": [...] }
|
||||||
|
POST /admin/users ← { "username", "password", "email", "role" }
|
||||||
|
PUT /admin/users/:id/role ← { "role": "admin|user" }
|
||||||
|
PUT /admin/users/:id/active ← { "active": false }
|
||||||
|
DELETE /admin/users/:id
|
||||||
|
POST /admin/users/:id/reset-password ← { "new_password": "..." }
|
||||||
|
POST /admin/users/:id/vault/reset → destroys user's UEK (BYOK keys lost)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Global Settings & Policies
|
||||||
|
|
||||||
|
Settings are key-value pairs in `global_settings`. Policies are
|
||||||
|
boolean flags that gate features.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/settings → all settings
|
||||||
|
GET /admin/settings/:key → single setting
|
||||||
|
PUT /admin/settings/:key ← { "value": ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
The handler auto-detects: if the value is a string and the key is a
|
||||||
|
known policy name, it writes to the `policies` table. Otherwise it
|
||||||
|
writes to `global_config` as JSON.
|
||||||
|
|
||||||
|
**Public settings** (non-admin, for FE bootstrapping):
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /settings/public
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"banner": { "enabled": true, "text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff", "position": "both" },
|
||||||
|
"branding": { "instance_name": "Switchboard", "logo_url": "...", "tagline": "..." },
|
||||||
|
"has_admin_prompt": true,
|
||||||
|
"storage_configured": true,
|
||||||
|
"paste_to_file_chars": 2000,
|
||||||
|
"policies": {
|
||||||
|
"allow_registration": "true",
|
||||||
|
"allow_user_byok": "true",
|
||||||
|
"allow_user_personas": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Banner Configuration
|
||||||
|
|
||||||
|
The environment banner is stored as a single `global_config` entry
|
||||||
|
under the key `"banner"`. It's a simple object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"text": "DEVELOPMENT",
|
||||||
|
"position": "both|top|bottom",
|
||||||
|
"bg": "#007a33",
|
||||||
|
"fg": "#ffffff"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Set via `PUT /admin/settings/banner` with `{ "value": { ... } }`.
|
||||||
|
The admin UI provides a color picker, position selector, and preset
|
||||||
|
dropdown. The Go template base layout reads the banner from
|
||||||
|
`PageData` and renders top/bottom strips with CSS custom properties.
|
||||||
|
|
||||||
|
### Platform Stats
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"users": 42,
|
||||||
|
"channels": 156,
|
||||||
|
"messages": 12847
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage & Vault
|
||||||
|
|
||||||
|
**Storage status:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/storage/status → { "backend", "healthy", "file_count", "total_bytes", ... }
|
||||||
|
GET /admin/storage/orphans → { "count": 3 }
|
||||||
|
POST /admin/storage/cleanup → removes orphaned blobs
|
||||||
|
GET /admin/storage/extraction → extraction queue status
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vault:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/vault/status → { "encryption_key_set", "user_vaults_count", ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Audit Log
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/audit?page=1&per_page=50&action=user.create&actor_id=uuid
|
||||||
|
GET /admin/audit/actions → { "actions": ["user.create", "policy.update", ...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit entry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"actor_id": "uuid",
|
||||||
|
"action": "user.create",
|
||||||
|
"resource_type": "user",
|
||||||
|
"resource_id": "uuid",
|
||||||
|
"metadata": {},
|
||||||
|
"created_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage & Pricing
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/usage → { "totals", "results" }
|
||||||
|
GET /admin/usage/teams/:id → team-specific usage
|
||||||
|
GET /admin/usage/users/:id → user-specific usage
|
||||||
|
GET /admin/pricing → { "data": [...] }
|
||||||
|
PUT /admin/pricing ← { "provider", "model", "input_per_m", "output_per_m" }
|
||||||
|
DELETE /admin/pricing/:provider/:model
|
||||||
|
```
|
||||||
|
|
||||||
|
**Personal usage** (non-admin):
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /usage → { "totals", "results" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Scoped to the user's BYOK provider usage only.
|
||||||
|
|
||||||
|
### Roles (Platform)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/roles → { "roles": [...] }
|
||||||
|
GET /admin/roles/:role
|
||||||
|
PUT /admin/roles/:role ← { "permissions": {...} }
|
||||||
|
POST /admin/roles/:role/test ← test role permissions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Email Test
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /admin/notifications/test-email
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "to": "admin@example.com" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Sends a test email using the configured SMTP settings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Archived Channels
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/channels/archived → { "data": [...] }
|
||||||
|
DELETE /admin/channels/:id/purge → permanent delete (ignores retention)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auth:** Platform admin.
|
||||||
|
|
||||||
|
### Surfaces Admin
|
||||||
|
|
||||||
|
See [surfaces.md](surfaces.md) for the full surface management API.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/surfaces → all surfaces (including disabled)
|
||||||
|
GET /admin/surfaces/:id → surface with manifest
|
||||||
|
POST /admin/surfaces/install ← multipart .surface archive
|
||||||
|
PUT /admin/surfaces/:id/enable
|
||||||
|
PUT /admin/surfaces/:id/disable
|
||||||
|
DELETE /admin/surfaces/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tasks Admin
|
||||||
|
|
||||||
|
See [tasks.md](tasks.md) for the full task management API.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/tasks → all tasks across all users/teams
|
||||||
|
POST /admin/tasks/:id/run → force run
|
||||||
|
POST /admin/tasks/:id/kill → cancel active run
|
||||||
|
DELETE /admin/tasks/:id → delete task
|
||||||
|
```
|
||||||
175
docs/ICD/auth.md
Normal file
175
docs/ICD/auth.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# Auth
|
||||||
|
|
||||||
|
Three auth modes, configured via `AUTH_MODE` env var. All three issue the
|
||||||
|
same JWT after authentication — downstream middleware is auth-mode-agnostic.
|
||||||
|
|
||||||
|
## Builtin Auth (default)
|
||||||
|
|
||||||
|
Username/password authentication with bcrypt hashing.
|
||||||
|
|
||||||
|
### Register
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /auth/register
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "jdoe",
|
||||||
|
"password": "...",
|
||||||
|
"email": "jdoe@example.com",
|
||||||
|
"display_name": "Jane Doe"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Gated by `allow_registration` policy. Returns same shape as Login.
|
||||||
|
A unique `handle` is auto-generated from `username` (collision-safe
|
||||||
|
with `-2`, `-3` suffixes).
|
||||||
|
|
||||||
|
### Login
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /auth/login
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "username": "jdoe", "password": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "eyJ...",
|
||||||
|
"refresh_token": "opaque-string",
|
||||||
|
"user": {
|
||||||
|
"id": "uuid",
|
||||||
|
"username": "jdoe",
|
||||||
|
"email": "jdoe@example.com",
|
||||||
|
"display_name": "Jane Doe",
|
||||||
|
"handle": "jdoe",
|
||||||
|
"role": "user",
|
||||||
|
"auth_source": "builtin",
|
||||||
|
"avatar_url": "/api/v1/profile/avatar?v=1234",
|
||||||
|
"created_at": "...",
|
||||||
|
"last_login": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Refresh
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /auth/refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "refresh_token": "opaque-string" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns new `access_token` and `refresh_token`. Old refresh token is
|
||||||
|
invalidated (rotation).
|
||||||
|
|
||||||
|
### Logout
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /auth/logout
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "refresh_token": "opaque-string" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Revokes the refresh token. Returns `{ "message": "logged out" }`.
|
||||||
|
|
||||||
|
## mTLS Auth
|
||||||
|
|
||||||
|
Mutual TLS via reverse proxy headers. The proxy terminates TLS and
|
||||||
|
forwards cert DN fields in headers. The backend auto-provisions
|
||||||
|
users on first connection.
|
||||||
|
|
||||||
|
**Configured by:** `AUTH_MODE=mtls` + `MTLS_HEADER_*` env vars.
|
||||||
|
|
||||||
|
No explicit login/register endpoints. User identity is extracted from
|
||||||
|
the TLS certificate on every request. `password_hash` is NULL for
|
||||||
|
mTLS users.
|
||||||
|
|
||||||
|
**Headers parsed:**
|
||||||
|
- `MTLS_HEADER_CN` (default: `X-SSL-Client-CN`) — common name → username
|
||||||
|
- `MTLS_HEADER_DN` (default: `X-SSL-Client-DN`) — distinguished name → metadata
|
||||||
|
- `MTLS_HEADER_VERIFY` (default: `X-SSL-Client-Verify`) — must be `SUCCESS`
|
||||||
|
- `MTLS_HEADER_FINGERPRINT` (default: `X-SSL-Client-Fingerprint`) — stable identity
|
||||||
|
|
||||||
|
On first request with a valid cert, the system creates a user with
|
||||||
|
`auth_source=mtls` and `external_id=fingerprint`.
|
||||||
|
|
||||||
|
## OIDC Auth
|
||||||
|
|
||||||
|
OpenID Connect authorization code flow. Tested with Keycloak but
|
||||||
|
compatible with any OIDC-compliant IdP.
|
||||||
|
|
||||||
|
**Configured by:** `AUTH_MODE=oidc` + `OIDC_*` env vars.
|
||||||
|
|
||||||
|
### OIDC Login (redirect)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /auth/oidc/login
|
||||||
|
```
|
||||||
|
|
||||||
|
Redirects to the IdP authorization endpoint. Stores `state` + `nonce`
|
||||||
|
in `oidc_auth_state` table (cleaned up after callback).
|
||||||
|
|
||||||
|
### OIDC Callback
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /auth/oidc/callback?code=...&state=...
|
||||||
|
```
|
||||||
|
|
||||||
|
Exchanges authorization code for tokens, validates ID token signature
|
||||||
|
via JWKS, extracts claims. Auto-provisions user on first login with
|
||||||
|
`auth_source=oidc` and `external_id=sub`. Returns HTML fragment that
|
||||||
|
passes tokens to the frontend via URL fragment.
|
||||||
|
|
||||||
|
**Claim mapping:**
|
||||||
|
- `sub` → `external_id`
|
||||||
|
- `preferred_username` → `username` + `handle`
|
||||||
|
- `email` → `email`
|
||||||
|
- `name` → `display_name`
|
||||||
|
- `groups` → synced to internal groups with `source=oidc`
|
||||||
|
|
||||||
|
**Split-horizon issuer:** `OIDC_EXTERNAL_ISSUER_URL` can differ from
|
||||||
|
`OIDC_ISSUER_URL` when the IdP's internal address (Docker/K8s service)
|
||||||
|
differs from its external address.
|
||||||
|
|
||||||
|
**OIDC env vars:**
|
||||||
|
|
||||||
|
| Var | Required | Description |
|
||||||
|
|-----|----------|-------------|
|
||||||
|
| `OIDC_ISSUER_URL` | Yes | IdP issuer URL for discovery |
|
||||||
|
| `OIDC_EXTERNAL_ISSUER_URL` | No | External issuer (if different from internal) |
|
||||||
|
| `OIDC_CLIENT_ID` | Yes | Client ID |
|
||||||
|
| `OIDC_CLIENT_SECRET` | Yes | Client secret |
|
||||||
|
| `OIDC_REDIRECT_URL` | No | Callback URL (auto-derived if not set) |
|
||||||
|
|
||||||
|
## Login Page Adaptation
|
||||||
|
|
||||||
|
The `/login` page adapts by `AUTH_MODE`:
|
||||||
|
|
||||||
|
| Mode | UI |
|
||||||
|
|------|-----|
|
||||||
|
| `builtin` | Username/password form + register link |
|
||||||
|
| `mtls` | Certificate status display |
|
||||||
|
| `oidc` | "Sign in with SSO" button |
|
||||||
|
|
||||||
|
## User Identity Fields
|
||||||
|
|
||||||
|
All auth modes produce users with:
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `auth_source` | `builtin`, `mtls`, or `oidc` |
|
||||||
|
| `external_id` | IdP subject (OIDC) or cert fingerprint (mTLS). NULL for builtin. |
|
||||||
|
| `handle` | Unique @mention handle. Auto-generated, collision-safe. |
|
||||||
|
|
||||||
|
`handle` is the canonical identifier for @mentions (replacing username
|
||||||
|
in the resolution chain since v0.24.0).
|
||||||
711
docs/ICD/channels.md
Normal file
711
docs/ICD/channels.md
Normal file
@@ -0,0 +1,711 @@
|
|||||||
|
# Channels & Conversations
|
||||||
|
|
||||||
|
The **channel** is the universal conversation container. Messages,
|
||||||
|
tool activity, attachments, and KB bindings all hang off a channel.
|
||||||
|
Channels support multiple participants — users, personas, and
|
||||||
|
sessions — making them the foundation for collaborative and
|
||||||
|
multi-model workflows.
|
||||||
|
|
||||||
|
### Channel CRUD
|
||||||
|
|
||||||
|
**List channels** — paginated, sorted by last activity.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /channels?page=1&per_page=50
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns pagination envelope. Each channel:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"title": "My Chat",
|
||||||
|
"type": "direct|dm|group|channel|workflow|service",
|
||||||
|
"description": "",
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
|
"provider_config_id": "uuid|null",
|
||||||
|
"system_prompt": "",
|
||||||
|
"project_id": "uuid|null",
|
||||||
|
"folder": "string|null",
|
||||||
|
"tags": ["tag1", "tag2"],
|
||||||
|
"participant_count": 1,
|
||||||
|
"created_at": "...",
|
||||||
|
"updated_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Channel types:**
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `direct` | Single-user chat (default, backward compatible) |
|
||||||
|
| `dm` | Human-to-human direct message, AI silent unless @mentioned |
|
||||||
|
| `group` | Multi-user/multi-model collaborative channel |
|
||||||
|
| `channel` | Named persistent space, configurable ai_mode |
|
||||||
|
| `workflow` | Staged channel driven by workflow definitions |
|
||||||
|
| `service` | Autonomous task execution, no human participant |
|
||||||
|
|
||||||
|
**Create channel:**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /channels
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "New Chat",
|
||||||
|
"type": "direct",
|
||||||
|
"description": "",
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
|
"provider_config_id": "uuid",
|
||||||
|
"system_prompt": "",
|
||||||
|
"folder": null,
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
On create, the authenticated user is automatically added as a
|
||||||
|
participant with `role: "owner"`. If `model` and `provider_config_id`
|
||||||
|
are provided, the channel model roster (`channel_models`) is
|
||||||
|
auto-populated with an initial entry.
|
||||||
|
|
||||||
|
**Get channel:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /channels/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns single channel object.
|
||||||
|
|
||||||
|
**Update channel:**
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /channels/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepts partial updates — only fields present in the body are changed.
|
||||||
|
Same field set as create.
|
||||||
|
|
||||||
|
**Delete channel:**
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /channels/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Cascades: deletes messages, attachments, channel_models, channel_kbs.
|
||||||
|
|
||||||
|
### Message Tree
|
||||||
|
|
||||||
|
Switchboard uses a **tree** model for messages, not a flat list. Each
|
||||||
|
message has a `parent_id` (NULL for root). Editing creates a sibling
|
||||||
|
branch; regeneration creates an alternative sibling. The **cursor**
|
||||||
|
tracks which child is "active" at each branch point.
|
||||||
|
|
||||||
|
**Get active path** — the primary endpoint for loading chat history:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /channels/:id/path
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the messages along the active branch from root to leaf,
|
||||||
|
following the cursor at each fork. This is what the UI renders.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"channel_id": "uuid",
|
||||||
|
"parent_id": "uuid|null",
|
||||||
|
"role": "user|assistant|system|tool",
|
||||||
|
"content": "...",
|
||||||
|
"model": "claude-sonnet-4-20250514|null",
|
||||||
|
"provider_config_id": "uuid|null",
|
||||||
|
"participant_id": "uuid|null",
|
||||||
|
"participant_type": "user|persona|session|null",
|
||||||
|
"thinking": "...|null",
|
||||||
|
"tool_calls": [...],
|
||||||
|
"tool_results": [...],
|
||||||
|
"attachments": [...],
|
||||||
|
"has_siblings": true,
|
||||||
|
"sibling_index": 0,
|
||||||
|
"sibling_count": 2,
|
||||||
|
"created_at": "..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`participant_id` and `participant_type` identify who authored the
|
||||||
|
message. For `user` messages, this is the authenticated user. For
|
||||||
|
`assistant` messages, this is the persona (if set) or null for raw
|
||||||
|
model responses. Existing messages without participants return null.
|
||||||
|
|
||||||
|
**List all messages** — includes all branches, flat:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /channels/:id/messages
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy/debug endpoint. Returns every message in the channel regardless
|
||||||
|
of branch. Not used by the standard frontend.
|
||||||
|
|
||||||
|
**Create message:**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /channels/:id/messages
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hello",
|
||||||
|
"parent_id": "uuid|null",
|
||||||
|
"attachments": ["attachment-uuid-1"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edit message** — creates a sibling at the same level:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /channels/:id/messages/:msgId/edit
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "content": "Revised question" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates a new message with the same `parent_id` as the original.
|
||||||
|
Automatically updates the cursor to point to the new sibling.
|
||||||
|
|
||||||
|
**Regenerate** — creates an alternative assistant response:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /channels/:id/messages/:msgId/regenerate
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
|
"provider_config_id": "uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates a new empty assistant message as a sibling of `msgId`,
|
||||||
|
then streams the completion into it. The cursor is updated.
|
||||||
|
|
||||||
|
**Update cursor** — switch to a different branch:
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /channels/:id/cursor
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message_id": "uuid",
|
||||||
|
"child_id": "uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sets which child is active at the `message_id` branch point.
|
||||||
|
Subsequent `GET /channels/:id/path` will follow the new branch.
|
||||||
|
|
||||||
|
**List siblings** — all children of a message's parent:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /channels/:id/messages/:msgId/siblings
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "siblings": [...] }` with abbreviated message objects
|
||||||
|
(id, role, content preview, created_at).
|
||||||
|
|
||||||
|
### Completions & Streaming
|
||||||
|
|
||||||
|
The single most complex endpoint. Sends a message to an LLM provider
|
||||||
|
and streams the response via Server-Sent Events.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /chat/completions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channel_id": "uuid",
|
||||||
|
"message": "User's message text",
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
|
"provider_config_id": "uuid",
|
||||||
|
"persona_id": "uuid|null",
|
||||||
|
"parent_id": "uuid|null",
|
||||||
|
"tools_enabled": true,
|
||||||
|
"tool_ids": ["web_search", "calculator"],
|
||||||
|
"attachments": ["attachment-uuid"],
|
||||||
|
"extra_body": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`channel_id` is required.
|
||||||
|
|
||||||
|
`extra_body` is a freeform JSON object merged into the provider request
|
||||||
|
(provider-specific parameters like `temperature`, `reasoning`, etc.).
|
||||||
|
|
||||||
|
**Participant scoping:** The requesting user must be a participant in
|
||||||
|
the channel. Messages are attributed to the authenticated user (or
|
||||||
|
session). In `group` channels, all participants see all messages in
|
||||||
|
real time via WebSocket.
|
||||||
|
|
||||||
|
**Persona resolution:** The primary completion path. If `persona_id`
|
||||||
|
is set (explicitly or via `@mention` resolution from message content),
|
||||||
|
the handler loads the persona's system prompt, model, provider config,
|
||||||
|
and KB bindings. Per-message `model`/`provider_config_id` override the
|
||||||
|
persona's defaults. In multi-persona channels, `@mention` in the
|
||||||
|
message content is parsed to resolve the target persona from the
|
||||||
|
channel's participant list.
|
||||||
|
|
||||||
|
**Routing resolution:** After config resolution, the routing policy
|
||||||
|
evaluator (`evaluateRouting()`) may redirect to a different provider
|
||||||
|
based on active policies (see §10.4). The winning provider config's
|
||||||
|
credentials are loaded and used for the actual API call.
|
||||||
|
|
||||||
|
**SSE Stream Format:**
|
||||||
|
|
||||||
|
The response is `Content-Type: text/event-stream`. Events:
|
||||||
|
|
||||||
|
```
|
||||||
|
data: {"content":"Hello"}
|
||||||
|
|
||||||
|
data: {"content":" world"}
|
||||||
|
|
||||||
|
data: {"reasoning":"Let me think..."}
|
||||||
|
|
||||||
|
event: tool_use
|
||||||
|
data: {"id":"call_1","name":"web_search","input":{"query":"..."}}
|
||||||
|
|
||||||
|
event: tool_result
|
||||||
|
data: {"id":"call_1","content":"Search results..."}
|
||||||
|
|
||||||
|
data: {"content":"Based on the search..."}
|
||||||
|
|
||||||
|
event: finish
|
||||||
|
data: {"message_id":"uuid","model":"claude-sonnet-4-20250514","usage":{"input_tokens":150,"output_tokens":42}}
|
||||||
|
|
||||||
|
data: [DONE]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Event | `event:` field | Payload |
|
||||||
|
|-------|---------------|---------|
|
||||||
|
| Content delta | _(unnamed)_ | `{ "content": "text" }` |
|
||||||
|
| Reasoning delta | _(unnamed)_ | `{ "reasoning": "text" }` |
|
||||||
|
| Tool invocation | `tool_use` | `{ "id", "name", "input" }` |
|
||||||
|
| Tool result | `tool_result` | `{ "id", "content" }` |
|
||||||
|
| Stream complete | `finish` | `{ "message_id", "model", "usage" }` |
|
||||||
|
| End sentinel | _(unnamed)_ | `[DONE]` (literal string) |
|
||||||
|
| Error mid-stream | `error` | `{ "error": "message" }` |
|
||||||
|
|
||||||
|
**Tool cycle:** Tool use and tool result events can repeat up to
|
||||||
|
`max_tool_iterations` (configured server-side). The handler executes
|
||||||
|
tools, feeds results back to the model, and continues streaming.
|
||||||
|
|
||||||
|
**Response headers:**
|
||||||
|
|
||||||
|
```
|
||||||
|
X-Switchboard-Provider: providerID/configID
|
||||||
|
X-Switchboard-Route: policy-name (when routing policy is active)
|
||||||
|
```
|
||||||
|
|
||||||
|
**List available tools:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /tools
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "tools": [...] }` where each tool has `name`, `description`,
|
||||||
|
`parameters` (JSON Schema), and `category`.
|
||||||
|
|
||||||
|
### Multi-Model Roster (Raw Access)
|
||||||
|
|
||||||
|
The primary way to add AI to a channel is by adding a **persona as a
|
||||||
|
participant** (§3.7). Adding a persona automatically populates the
|
||||||
|
channel model roster with the persona's underlying model.
|
||||||
|
|
||||||
|
For the rare case where a user needs raw model access without a
|
||||||
|
persona wrapper, the `channel_models` table provides direct model
|
||||||
|
roster management. This is the 1% escape hatch — most users should
|
||||||
|
never need it.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /channels/:id/models → { "models": [...] }
|
||||||
|
POST /channels/:id/models ← { "model": "...", "provider_config_id": "...", "display_name": "..." }
|
||||||
|
PATCH /channels/:id/models/:modelId ← { "display_name": "..." }
|
||||||
|
DELETE /channels/:id/models/:modelId
|
||||||
|
```
|
||||||
|
|
||||||
|
Each roster entry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"channel_id": "uuid",
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
|
"provider_config_id": "uuid",
|
||||||
|
"display_name": "Claude",
|
||||||
|
"is_default": true,
|
||||||
|
"persona_id": "uuid|null",
|
||||||
|
"created_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`persona_id`: set when this roster entry was auto-created by adding a
|
||||||
|
persona participant. Null for manually-added raw models.
|
||||||
|
|
||||||
|
**`@mention` resolution:** When a user types `@` in a channel, the
|
||||||
|
autocomplete shows persona participants first (by display name), then
|
||||||
|
raw model roster entries. The target of a `@mention` is always
|
||||||
|
resolved to a specific `provider_config_id` + `model` pair — never
|
||||||
|
an ambiguous bare model name.
|
||||||
|
|
||||||
|
### Channel Utilities
|
||||||
|
|
||||||
|
**Summarize channel** (compaction):
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /channels/:id/summarize
|
||||||
|
```
|
||||||
|
|
||||||
|
Triggers conversation summarization via the utility model role.
|
||||||
|
Returns `{ "message": "summarized", "summary_id": "uuid" }`.
|
||||||
|
|
||||||
|
**Generate title:**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /channels/:id/generate-title
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses the utility model to auto-generate a title from the first
|
||||||
|
exchange. Returns `{ "title": "Generated Title" }`.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
All binary content associated with channels — user uploads, tool-
|
||||||
|
generated artifacts, system files — lives in the unified `files`
|
||||||
|
table backed by the ObjectStore (see §X). The old `attachments`
|
||||||
|
table and routes are removed.
|
||||||
|
|
||||||
|
**Upload (user):**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /channels/:id/files
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
Field: `file`. Sets `origin: "user_upload"`. Returns the file object.
|
||||||
|
|
||||||
|
**Create (tool output):**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /files
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channel_id": "uuid",
|
||||||
|
"message_id": "uuid",
|
||||||
|
"filename": "generated_image.png",
|
||||||
|
"content_type": "image/png",
|
||||||
|
"display_hint": "inline",
|
||||||
|
"metadata": { "tool_name": "image_generation" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Body: multipart `file` field or base64 `data` field.
|
||||||
|
Sets `origin: "tool_output"`. Returns file object.
|
||||||
|
|
||||||
|
**List by channel:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /channels/:id/files?origin=user_upload
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "files": [...] }`. Filterable by `origin`, `content_type`.
|
||||||
|
|
||||||
|
**List by message:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /messages/:id/files → { "files": [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
How the frontend discovers generated artifacts to render inline.
|
||||||
|
|
||||||
|
**List by user (file manager):**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /files?page=1&per_page=50 → { "files": [...], "total": N }
|
||||||
|
```
|
||||||
|
|
||||||
|
All files owned by the authenticated user, paginated.
|
||||||
|
|
||||||
|
**Get metadata:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /files/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"channel_id": "uuid",
|
||||||
|
"message_id": "uuid|null",
|
||||||
|
"user_id": "uuid",
|
||||||
|
"origin": "user_upload|tool_output|system",
|
||||||
|
"filename": "report.pdf",
|
||||||
|
"content_type": "application/pdf",
|
||||||
|
"size_bytes": 1048576,
|
||||||
|
"display_hint": "inline|download|thumbnail",
|
||||||
|
"extracted_text": "...|null",
|
||||||
|
"metadata": {},
|
||||||
|
"created_at": "...",
|
||||||
|
"updated_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Download:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /files/:id/download
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the file with appropriate `Content-Type` and
|
||||||
|
`Content-Disposition` headers.
|
||||||
|
|
||||||
|
**Thumbnail:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /files/:id/thumbnail
|
||||||
|
```
|
||||||
|
|
||||||
|
**Delete:**
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /files/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Deletes metadata + blob. Channel deletion cascades via FK +
|
||||||
|
`DeletePrefix`.
|
||||||
|
|
||||||
|
### Channel Participants
|
||||||
|
|
||||||
|
The participant system is how users and AI interact in a channel.
|
||||||
|
**Personas are the primary way to add AI** — adding a persona as a
|
||||||
|
participant brings its full identity (name, avatar, system prompt,
|
||||||
|
model, provider config, KB bindings) into the channel. This is the
|
||||||
|
99% path. Raw model access without a persona is available via the
|
||||||
|
model roster (§3.4) for power users.
|
||||||
|
|
||||||
|
Every channel has at least one participant (the owner). `direct`
|
||||||
|
channels have exactly one user participant and optionally one or more
|
||||||
|
persona participants. `group` and `workflow` channels support multiple
|
||||||
|
participants of mixed types.
|
||||||
|
|
||||||
|
**List participants:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /channels/:id/participants
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "participants": [...] }`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"channel_id": "uuid",
|
||||||
|
"participant_type": "user|persona|session",
|
||||||
|
"participant_id": "uuid",
|
||||||
|
"role": "owner|member|observer",
|
||||||
|
"display_name": "Jane Doe",
|
||||||
|
"avatar_url": "...|null",
|
||||||
|
"joined_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Participant types:**
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `user` | Authenticated user. `participant_id` = `users.id` |
|
||||||
|
| `persona` | AI persona added to channel. `participant_id` = `personas.id`. Brings model, system prompt, KB bindings |
|
||||||
|
| `session` | Anonymous/session participant (workflow intake). `participant_id` = session token |
|
||||||
|
|
||||||
|
**Participant roles:**
|
||||||
|
|
||||||
|
| Role | Capabilities |
|
||||||
|
|------|-------------|
|
||||||
|
| `owner` | Full control: add/remove participants, delete channel, all member capabilities |
|
||||||
|
| `member` | Send messages, trigger completions, view history |
|
||||||
|
| `observer` | Read-only: view messages, no send |
|
||||||
|
|
||||||
|
**Add participant:**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /channels/:id/participants
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"participant_type": "user|persona",
|
||||||
|
"participant_id": "uuid",
|
||||||
|
"role": "member"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `owner` role in the channel. Adding a `persona` participant:
|
||||||
|
- Makes the persona available as an `@mention` target
|
||||||
|
- Adds the persona's model to the channel model roster automatically
|
||||||
|
- Scopes the persona's KB bindings into the channel's KB resolution chain
|
||||||
|
- The persona's avatar and display name appear in the participant list
|
||||||
|
|
||||||
|
**Update participant role:**
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /channels/:id/participants/:participantId
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "role": "observer" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remove participant:**
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /channels/:id/participants/:participantId
|
||||||
|
```
|
||||||
|
|
||||||
|
Cannot remove the last owner. Removing a `persona` participant also
|
||||||
|
removes its auto-created model roster entry.
|
||||||
|
|
||||||
|
**Completion routing:** When `@PersonaName` appears in message
|
||||||
|
content, the completion handler resolves to that persona's model,
|
||||||
|
provider config, and system prompt. When no `@mention` is present in
|
||||||
|
a multi-persona channel, the channel's default persona (first added)
|
||||||
|
handles the response. The `persona_id` field in the completion
|
||||||
|
request (§3.3) can also be set explicitly to override `@mention`
|
||||||
|
resolution.
|
||||||
|
|
||||||
|
**Backward compatibility:** Existing `direct` channels that predate
|
||||||
|
the participant system are auto-migrated on first access — a single
|
||||||
|
`user` participant with `role: "owner"` is created from the channel's
|
||||||
|
legacy `user_id` column.
|
||||||
|
|
||||||
|
### Presence
|
||||||
|
|
||||||
|
Heartbeat-based online status. Not channel-scoped — tracks global user presence.
|
||||||
|
|
||||||
|
**Heartbeat:**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /presence/heartbeat
|
||||||
|
```
|
||||||
|
|
||||||
|
Client sends every 30 seconds. Server upserts `user_presence` row.
|
||||||
|
No request body needed. Returns `{ "ok": true }`.
|
||||||
|
|
||||||
|
**Auth:** Authenticated.
|
||||||
|
|
||||||
|
**Bulk query:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /presence?users=alice,bob,charlie
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns online status for listed usernames. Threshold: online if
|
||||||
|
last heartbeat < 90 seconds ago.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"users": {
|
||||||
|
"alice": { "status": "online", "last_seen": "..." },
|
||||||
|
"bob": { "status": "offline", "last_seen": "..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Subsequent updates delivered via WebSocket `presence.changed` events.
|
||||||
|
|
||||||
|
**Typing indicators** are sent via WebSocket (see websocket.md). The
|
||||||
|
typing event payload includes `participant_id` and `participant_type`.
|
||||||
|
|
||||||
|
### Chat Folders
|
||||||
|
|
||||||
|
User-scoped grouping for chats. Folders have no semantic weight — they
|
||||||
|
are named drawers.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /folders → list user's folders
|
||||||
|
POST /folders ← { "name": "Research" }
|
||||||
|
PUT /folders/:id ← { "name": "Renamed" }
|
||||||
|
DELETE /folders/:id → chats become unfiled
|
||||||
|
```
|
||||||
|
|
||||||
|
**Folder object:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"user_id": "uuid",
|
||||||
|
"name": "Research",
|
||||||
|
"parent_id": "uuid|null",
|
||||||
|
"sort_order": 0,
|
||||||
|
"created_at": "...",
|
||||||
|
"updated_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Moving a chat into/out of a folder is done via `PUT /channels/:id`
|
||||||
|
with `{ "folder_id": "uuid" }` or `{ "folder_id": null }`.
|
||||||
|
|
||||||
|
**Auth:** Authenticated (user-scoped).
|
||||||
|
|
||||||
|
### Channel Configuration
|
||||||
|
|
||||||
|
Additional channel fields (set via `PUT /channels/:id`):
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `ai_mode` | string | `auto` | `auto`, `mention_only`, or `off` |
|
||||||
|
| `topic` | string | null | Short description shown in header |
|
||||||
|
| `allow_anonymous` | boolean | false | Session participants can join (workflow channels) |
|
||||||
|
| `kb_auto_inject` | boolean | false | Top-K KB chunks auto-prepend to context |
|
||||||
|
|
||||||
|
`ai_mode` behavioral matrix:
|
||||||
|
|
||||||
|
| Channel type | Default ai_mode | Notes |
|
||||||
|
|-------------|----------------|-------|
|
||||||
|
| `direct` | `auto` | Existing behavior |
|
||||||
|
| `dm` | `mention_only` | AI silent unless @mentioned |
|
||||||
|
| `group` | `auto` | Configurable per channel |
|
||||||
|
| `channel` | `auto` | Configurable per channel |
|
||||||
|
|
||||||
|
### Mark Read
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /channels/:id/mark-read
|
||||||
|
```
|
||||||
|
|
||||||
|
Updates the user's `last_read_at` cursor. Used for unread count
|
||||||
|
calculation.
|
||||||
|
|
||||||
|
**Auth:** Authenticated.
|
||||||
|
|
||||||
|
### User Search
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /users/search?q=alice
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "data": [{ "id", "username", "handle", "display_name", "avatar_url" }] }`.
|
||||||
|
Used for DM creation and participant picker. Matches on handle.
|
||||||
|
|
||||||
|
**Auth:** Authenticated.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
250
docs/ICD/enums.md
Normal file
250
docs/ICD/enums.md
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# Appendix: Enums & Constants
|
||||||
|
|
||||||
|
All enum values used across the API. Definitive source of truth.
|
||||||
|
|
||||||
|
## Channel Types
|
||||||
|
|
||||||
|
| Value | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `direct` | Single-user chat (default, backward compatible) |
|
||||||
|
| `dm` | Human-to-human direct message, AI silent unless @mentioned |
|
||||||
|
| `group` | Multi-user/multi-model collaborative channel |
|
||||||
|
| `channel` | Named persistent space, configurable ai_mode |
|
||||||
|
| `workflow` | Staged channel driven by workflow definitions |
|
||||||
|
| `service` | Autonomous task execution, no human participant |
|
||||||
|
|
||||||
|
## Channel AI Modes
|
||||||
|
|
||||||
|
| Value | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `auto` | AI responds to every message (default) |
|
||||||
|
| `mention_only` | AI responds only when @mentioned (default for `dm`) |
|
||||||
|
| `off` | AI disabled entirely |
|
||||||
|
|
||||||
|
## Participant Types
|
||||||
|
|
||||||
|
| Value | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `user` | Authenticated user. `participant_id` = `users.id` |
|
||||||
|
| `persona` | AI persona. `participant_id` = `personas.id` |
|
||||||
|
| `session` | Anonymous visitor. `participant_id` = session token |
|
||||||
|
|
||||||
|
## Participant Roles
|
||||||
|
|
||||||
|
| Value | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `owner` | Full control: add/remove participants, delete channel |
|
||||||
|
| `member` | Send messages, trigger completions, view history |
|
||||||
|
| `observer` | Read-only: view messages, no send |
|
||||||
|
| `visitor` | Anonymous session participant (workflow intake) |
|
||||||
|
|
||||||
|
## Presence Statuses
|
||||||
|
|
||||||
|
`online`, `away`, `offline`
|
||||||
|
|
||||||
|
## Message Roles
|
||||||
|
|
||||||
|
`user`, `assistant`, `system`, `tool`
|
||||||
|
|
||||||
|
## Scopes
|
||||||
|
|
||||||
|
`personal`, `team`, `global`
|
||||||
|
|
||||||
|
## Visibility (Model Catalog)
|
||||||
|
|
||||||
|
`enabled`, `disabled`, `team`
|
||||||
|
|
||||||
|
## User Roles
|
||||||
|
|
||||||
|
`admin`, `user`
|
||||||
|
|
||||||
|
## Team Member Roles
|
||||||
|
|
||||||
|
`admin`, `member`
|
||||||
|
|
||||||
|
## Provider Types
|
||||||
|
|
||||||
|
`openai`, `anthropic`, `openrouter`, `venice` (extensible via registry)
|
||||||
|
|
||||||
|
## Model Types
|
||||||
|
|
||||||
|
`chat`, `embedding`, `image`
|
||||||
|
|
||||||
|
## Memory Scopes
|
||||||
|
|
||||||
|
`user`, `persona`, `persona_user`
|
||||||
|
|
||||||
|
## Memory Statuses
|
||||||
|
|
||||||
|
`active`, `pending_review`, `archived`
|
||||||
|
|
||||||
|
## Workspace Owner Types
|
||||||
|
|
||||||
|
`user`, `project`, `channel`, `team`
|
||||||
|
|
||||||
|
## Workspace Statuses
|
||||||
|
|
||||||
|
`active`, `archived`, `deleting`
|
||||||
|
|
||||||
|
## File Index Statuses
|
||||||
|
|
||||||
|
`pending`, `indexing`, `ready`, `error`, `skipped`
|
||||||
|
|
||||||
|
## KB Document Statuses
|
||||||
|
|
||||||
|
`pending`, `chunking`, `embedding`, `ready`, `error`
|
||||||
|
|
||||||
|
## Provider Health Statuses
|
||||||
|
|
||||||
|
`healthy`, `degraded`, `down`
|
||||||
|
|
||||||
|
## Routing Policy Types
|
||||||
|
|
||||||
|
| Value | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `provider_prefer` | Ordered provider fallback list |
|
||||||
|
| `team_route` | Restrict team to specific providers |
|
||||||
|
| `cost_limit` | Heuristic cost cap per request |
|
||||||
|
| `model_alias` | Alias → provider+model rewrite |
|
||||||
|
| `capability_match` | Match cheapest model with required capabilities |
|
||||||
|
|
||||||
|
## Extension Tiers
|
||||||
|
|
||||||
|
`browser` (implemented), `starlark` (future), `sidecar` (future)
|
||||||
|
|
||||||
|
## Grant Types
|
||||||
|
|
||||||
|
| Value | Visibility |
|
||||||
|
|-------|-----------|
|
||||||
|
| `team_only` | Only the owning team |
|
||||||
|
| `global` | All authenticated users |
|
||||||
|
| `groups` | Members of specified groups |
|
||||||
|
|
||||||
|
## Resource Grant Resource Types
|
||||||
|
|
||||||
|
`persona`, `knowledge_base`, `project`
|
||||||
|
|
||||||
|
## Group Sources
|
||||||
|
|
||||||
|
| Value | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `manual` | Admin-created |
|
||||||
|
| `oidc` | Synced from IdP groups claim |
|
||||||
|
| `system` | Platform-seeded (e.g., Everyone group) |
|
||||||
|
|
||||||
|
## Auth Sources
|
||||||
|
|
||||||
|
| Value | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `builtin` | Username/password, bcrypt |
|
||||||
|
| `mtls` | Mutual TLS via reverse proxy |
|
||||||
|
| `oidc` | OpenID Connect (Keycloak etc.) |
|
||||||
|
|
||||||
|
## Workflow Statuses
|
||||||
|
|
||||||
|
| Value | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `active` | Instance is running |
|
||||||
|
| `completed` | All stages finished |
|
||||||
|
| `stale` | No activity within threshold |
|
||||||
|
| `cancelled` | Manually cancelled |
|
||||||
|
|
||||||
|
## Workflow Entry Modes
|
||||||
|
|
||||||
|
`public_link`, `team_only`
|
||||||
|
|
||||||
|
## Workflow History Modes
|
||||||
|
|
||||||
|
| Value | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `full` | Next stage sees complete chat history |
|
||||||
|
| `summary` | Utility-role summary of previous stages |
|
||||||
|
| `fresh` | Clean slate — no history carried forward |
|
||||||
|
|
||||||
|
## Task Types
|
||||||
|
|
||||||
|
`prompt`, `workflow`
|
||||||
|
|
||||||
|
## Task Run Statuses
|
||||||
|
|
||||||
|
| Value | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `running` | Currently executing |
|
||||||
|
| `completed` | Finished successfully |
|
||||||
|
| `failed` | Error during execution |
|
||||||
|
| `budget_exceeded` | Hit token/tool/wall-clock limit |
|
||||||
|
| `cancelled` | Manually killed |
|
||||||
|
|
||||||
|
## Task Scopes
|
||||||
|
|
||||||
|
`personal`, `team`, `global`
|
||||||
|
|
||||||
|
## Task Output Modes
|
||||||
|
|
||||||
|
`channel`, `note`, `webhook`
|
||||||
|
|
||||||
|
## Assignment Statuses
|
||||||
|
|
||||||
|
`unassigned`, `claimed`, `completed`
|
||||||
|
|
||||||
|
## Surface Sources
|
||||||
|
|
||||||
|
`core`, `extension`
|
||||||
|
|
||||||
|
## Notification Types
|
||||||
|
|
||||||
|
`role.fallback`, `memory.extracted`, `system.announcement`,
|
||||||
|
`workflow.assigned`, `workflow.claimed`, `task.completed`,
|
||||||
|
`task.failed`, `user.mentioned`
|
||||||
|
|
||||||
|
## Git Auth Types
|
||||||
|
|
||||||
|
`https_pat`, `https_basic`, `ssh_key`
|
||||||
|
|
||||||
|
## File Origins
|
||||||
|
|
||||||
|
`user_upload`, `tool_output`, `system`
|
||||||
|
|
||||||
|
## File Display Hints
|
||||||
|
|
||||||
|
`inline`, `download`, `thumbnail`
|
||||||
|
|
||||||
|
## Proxy Modes
|
||||||
|
|
||||||
|
`system`, `direct`, `custom`
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
12 permission constants, `domain.action` convention:
|
||||||
|
|
||||||
|
| Permission | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `model.use` | Use AI models for completions |
|
||||||
|
| `kb.read` | Search knowledge bases |
|
||||||
|
| `kb.create` | Create knowledge bases |
|
||||||
|
| `kb.write` | Upload documents to KBs |
|
||||||
|
| `channel.create` | Create new channels |
|
||||||
|
| `channel.invite` | Add participants to channels |
|
||||||
|
| `persona.create` | Create personal personas |
|
||||||
|
| `persona.manage` | Edit/delete own personas |
|
||||||
|
| `workflow.create` | Create/edit workflow definitions |
|
||||||
|
| `tasks.create` | Create/manage tasks |
|
||||||
|
| `tasks.admin` | Manage all tasks (admin) |
|
||||||
|
| `admin.access` | Access admin panel |
|
||||||
|
|
||||||
|
Permissions are granted via groups. The `Everyone` group
|
||||||
|
(ID `00000000-0000-0000-0000-000000000001`) provides default
|
||||||
|
permissions to all authenticated users. It is system-seeded
|
||||||
|
and editable by admins.
|
||||||
|
|
||||||
|
## Policies
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `allow_registration` | `"true"` | Allow new user self-registration |
|
||||||
|
| `allow_user_byok` | `"false"` | Allow users to add personal API keys |
|
||||||
|
| `allow_user_personas` | `"false"` | Allow users to create personal Personas |
|
||||||
|
| `allow_team_providers` | `"true"` | Allow team admins to configure team providers |
|
||||||
|
| `allow_raw_model_access` | `"true"` | Allow raw model selection without persona |
|
||||||
|
| `kb_direct_access` | `"true"` | Show KB picker in channel (false = strict enterprise) |
|
||||||
|
| `default_user_active` | `"false"` | New users active immediately (vs admin approval) |
|
||||||
34
docs/ICD/extensions.md
Normal file
34
docs/ICD/extensions.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Extensions
|
||||||
|
|
||||||
|
Plugin system with three tiers: Browser JS (client-side), Starlark
|
||||||
|
sandbox (server-side, future), Sidecar containers (server-side, future).
|
||||||
|
Currently only Browser tier is implemented.
|
||||||
|
|
||||||
|
### User Extensions
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /extensions → { "extensions": [...] }
|
||||||
|
POST /extensions/:id/settings ← { "enabled": true, "config": {...} }
|
||||||
|
GET /extensions/:id/manifest → full manifest.json
|
||||||
|
GET /extensions/tools → { "tools": [tool schema objects] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin Extension Management
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/extensions → { "extensions": [...] }
|
||||||
|
POST /admin/extensions ← { manifest + script content }
|
||||||
|
PUT /admin/extensions/:id ← updated manifest/script
|
||||||
|
DELETE /admin/extensions/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Asset Serving
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /extensions/:id/assets/*path
|
||||||
|
```
|
||||||
|
|
||||||
|
Public (no auth required). Serves static assets (icons, CSS, JS)
|
||||||
|
from extension bundles.
|
||||||
|
|
||||||
|
---
|
||||||
167
docs/ICD/knowledge.md
Normal file
167
docs/ICD/knowledge.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# Knowledge Bases
|
||||||
|
|
||||||
|
The **Knowledge Base** (KB) is the RAG system: upload documents → chunk
|
||||||
|
→ embed (pgvector / app-level cosine for SQLite) → search via
|
||||||
|
`kb_search` tool during completions.
|
||||||
|
|
||||||
|
### KB CRUD
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /knowledge-bases → { "data": [KB objects] }
|
||||||
|
POST /knowledge-bases ← { "name", "description", "scope", "team_id" }
|
||||||
|
GET /knowledge-bases/:id → KB object
|
||||||
|
PUT /knowledge-bases/:id ← partial update
|
||||||
|
DELETE /knowledge-bases/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
KB object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "Product Docs",
|
||||||
|
"description": "Internal product documentation",
|
||||||
|
"scope": "personal|team|global",
|
||||||
|
"owner_id": "uuid|null",
|
||||||
|
"team_id": "uuid|null",
|
||||||
|
"discoverable": true,
|
||||||
|
"embedding_model": "text-embedding-3-small",
|
||||||
|
"chunk_size": 512,
|
||||||
|
"chunk_overlap": 50,
|
||||||
|
"document_count": 12,
|
||||||
|
"created_at": "...",
|
||||||
|
"updated_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Documents
|
||||||
|
|
||||||
|
**Upload:**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /knowledge-bases/:id/documents
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
Field: `file`. Supported: PDF, DOCX, TXT, MD, HTML, CSV, XLSX, PPTX.
|
||||||
|
Returns the document object with `status: "pending"`.
|
||||||
|
|
||||||
|
**List:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /knowledge-bases/:id/documents
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "data": [document objects] }`.
|
||||||
|
|
||||||
|
**Status** (poll during processing):
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /knowledge-bases/:id/documents/:docId/status
|
||||||
|
```
|
||||||
|
|
||||||
|
Status progression: `pending → chunking → embedding → ready | error`.
|
||||||
|
|
||||||
|
**Delete:**
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /knowledge-bases/:id/documents/:docId
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /knowledge-bases/:id/search
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "How do I configure SSO?",
|
||||||
|
"limit": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "results": [{ "content", "score", "document_id", "metadata" }] }`.
|
||||||
|
|
||||||
|
### Rebuild
|
||||||
|
|
||||||
|
Re-chunks and re-embeds all documents:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /knowledge-bases/:id/rebuild
|
||||||
|
```
|
||||||
|
|
||||||
|
### Channel KB Bindings
|
||||||
|
|
||||||
|
A channel can have KBs explicitly bound to it (in addition to any KBs
|
||||||
|
coming from the active Persona or parent Project).
|
||||||
|
|
||||||
|
**Get:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /channels/:id/knowledge-bases
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "data": [ChannelKB objects] }`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kb_id": "uuid",
|
||||||
|
"kb_name": "Product Docs",
|
||||||
|
"enabled": true,
|
||||||
|
"document_count": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Set** (replace all):
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /channels/:id/knowledge-bases
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "kb_ids": ["kb-uuid-1", "kb-uuid-2"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Discoverable KBs
|
||||||
|
|
||||||
|
The `discoverable` flag controls whether a KB appears in the user's KB
|
||||||
|
picker. Non-discoverable KBs are hidden from direct access but still
|
||||||
|
searchable through Persona bindings (enterprise KB mode).
|
||||||
|
|
||||||
|
**List discoverable** (for KB picker UI):
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /knowledge-bases-discoverable
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "data": [KB objects] }` — only KBs the user can see
|
||||||
|
(personal + team + discoverable global).
|
||||||
|
|
||||||
|
**Toggle discoverability** (admin-enforced in handler):
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /knowledge-bases/:id/discoverable
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "discoverable": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
### KB Resolution Chain
|
||||||
|
|
||||||
|
When a completion fires, KBs are resolved in priority order:
|
||||||
|
|
||||||
|
```
|
||||||
|
Persona KBs → Project KBs → Channel KBs → Personal KBs
|
||||||
|
```
|
||||||
|
|
||||||
|
The `BuildKBHint` function assembles all applicable KBs into the
|
||||||
|
system prompt, and the `kb_search` tool searches across all of them.
|
||||||
|
|
||||||
|
### Persona KB Bindings
|
||||||
|
|
||||||
|
See §4.4. Persona-KB binding is the primary enterprise mechanism for
|
||||||
|
controlled KB access.
|
||||||
|
|
||||||
|
---
|
||||||
48
docs/ICD/memory.md
Normal file
48
docs/ICD/memory.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Memory
|
||||||
|
|
||||||
|
**Long-term memory** extracted from conversations. The system
|
||||||
|
identifies facts, preferences, and context about the user and stores
|
||||||
|
them for injection into future conversations.
|
||||||
|
|
||||||
|
### User Memory
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /memories → { "data": [memory objects] }
|
||||||
|
GET /memories/count → { "count": 42 }
|
||||||
|
PUT /memories/:id ← { "content": "updated text" }
|
||||||
|
DELETE /memories/:id
|
||||||
|
POST /memories/:id/approve → approve a pending memory
|
||||||
|
POST /memories/:id/reject → reject a pending memory
|
||||||
|
```
|
||||||
|
|
||||||
|
Memory object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"user_id": "uuid",
|
||||||
|
"persona_id": "uuid|null",
|
||||||
|
"scope": "user|persona",
|
||||||
|
"content": "User prefers Go for backend work",
|
||||||
|
"source_channel_id": "uuid",
|
||||||
|
"status": "pending|approved|rejected",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"created_at": "...",
|
||||||
|
"updated_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`scope`: `user` memories apply across all conversations. `persona`
|
||||||
|
memories are specific to interactions with that Persona.
|
||||||
|
|
||||||
|
### Admin Memory Review
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/memories/pending → { "data": [memory objects with user info] }
|
||||||
|
POST /admin/memories/bulk-approve ← { "ids": ["uuid1", "uuid2"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Platform admin can review and bulk-approve pending memories across
|
||||||
|
all users.
|
||||||
|
|
||||||
|
---
|
||||||
88
docs/ICD/models.md
Normal file
88
docs/ICD/models.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Models & Preferences
|
||||||
|
|
||||||
|
What models are available to the current user and how they control
|
||||||
|
visibility.
|
||||||
|
|
||||||
|
### Enabled Models
|
||||||
|
|
||||||
|
The primary endpoint for populating model selectors:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /models/enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "models": [UserModel objects] }`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "composite-id",
|
||||||
|
"model_id": "claude-sonnet-4-20250514",
|
||||||
|
"provider_config_id": "uuid",
|
||||||
|
"provider_config_name": "Anthropic",
|
||||||
|
"provider": "anthropic",
|
||||||
|
"display_name": "Claude Sonnet 4",
|
||||||
|
"model_type": "chat|embedding|image",
|
||||||
|
"context_window": 200000,
|
||||||
|
"max_output_tokens": 8192,
|
||||||
|
"supports_vision": true,
|
||||||
|
"supports_tools": true,
|
||||||
|
"supports_thinking": true,
|
||||||
|
"supports_streaming": true,
|
||||||
|
"input_price_per_m": 3.00,
|
||||||
|
"output_price_per_m": 15.00,
|
||||||
|
"provider_status": "healthy|degraded|down|null",
|
||||||
|
"scope": "global|team|personal",
|
||||||
|
"source": "catalog|heuristic",
|
||||||
|
"is_persona": false,
|
||||||
|
"persona_id": "uuid|null",
|
||||||
|
"persona_scope": "global|team|personal|null",
|
||||||
|
"persona_avatar": "url|null",
|
||||||
|
"persona_team_name": "string|null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The capabilities on each model are resolved through the three-tier
|
||||||
|
chain: catalog DB → heuristic inference → admin overrides (see §10.5).
|
||||||
|
|
||||||
|
When `is_persona` is `true`, the capability fields (`context_window`,
|
||||||
|
`max_output_tokens`, `supports_*`, pricing, `provider_status`) are
|
||||||
|
inherited from the persona's underlying model. The frontend renders
|
||||||
|
the same capability pills and badges for a persona as for its raw
|
||||||
|
model — the persona adds identity, not capability restrictions.
|
||||||
|
|
||||||
|
### User Model Preferences
|
||||||
|
|
||||||
|
Users can hide models they don't want to see and set per-model
|
||||||
|
defaults. Preferences are keyed on the **composite identity**
|
||||||
|
`provider_config_id:model_id` — the same model from different
|
||||||
|
providers can have independent visibility.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /models/preferences → { "preferences": [PreferenceEntry objects] }
|
||||||
|
PUT /models/preferences ← { "model_id": "...", "provider_config_id": "uuid", "hidden": true }
|
||||||
|
POST /models/preferences/bulk ← { "entries": [{ "model_id": "...", "provider_config_id": "uuid", "hidden": true }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
PreferenceEntry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_id": "grok-4.1-fast",
|
||||||
|
"provider_config_id": "uuid",
|
||||||
|
"hidden": true,
|
||||||
|
"preferred_temperature": 0.7,
|
||||||
|
"preferred_max_tokens": 4096,
|
||||||
|
"sort_order": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Identity rule:** `provider_config_id` is required on write. The same
|
||||||
|
bare `model_id` from two different provider configs (e.g. global Venice
|
||||||
|
vs personal BYOK Venice) are independent preference entries. The
|
||||||
|
frontend composite ID format is `{provider_config_id}:{model_id}`.
|
||||||
|
|
||||||
|
**Persona preferences:** Personas are not in this table. A persona's
|
||||||
|
visibility is controlled by the grant system (§15.3) and the
|
||||||
|
`is_active` flag, not by model preferences.
|
||||||
|
|
||||||
|
---
|
||||||
105
docs/ICD/notes.md
Normal file
105
docs/ICD/notes.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Notes
|
||||||
|
|
||||||
|
**Obsidian-style** knowledge graph with `[[wikilinks]]`, backlinks,
|
||||||
|
graph visualization, and folder organization. Notes are user-scoped
|
||||||
|
(personal notebook). Future: channel-scoped notes for workflow
|
||||||
|
artifacts.
|
||||||
|
|
||||||
|
### CRUD
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /notes → paginated list of noteListItem
|
||||||
|
POST /notes ← { "title", "content", "folder" }
|
||||||
|
GET /notes/:id → full note object
|
||||||
|
PUT /notes/:id ← { "title", "content", "folder" }
|
||||||
|
DELETE /notes/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Note object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"user_id": "uuid",
|
||||||
|
"title": "Meeting Notes",
|
||||||
|
"content": "# Meeting Notes\n\nDiscussed [[Project Alpha]]...",
|
||||||
|
"folder": "work",
|
||||||
|
"source_message_id": "uuid|null",
|
||||||
|
"created_at": "...",
|
||||||
|
"updated_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`source_message_id` links a note back to the chat message that
|
||||||
|
created it (provenance for AI-generated notes).
|
||||||
|
|
||||||
|
`noteListItem` is the same but with `content` truncated or omitted.
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
**Full-text search:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /notes/search?q=project+alpha
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses `plainto_tsquery` (Postgres) or `LIKE` fallback (SQLite).
|
||||||
|
Returns `{ "data": [searchResult objects] }` with match highlights.
|
||||||
|
|
||||||
|
**Title search** (lightweight, for wikilink autocomplete):
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /notes/search-titles?q=proj
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "data": [{ "id", "title" }] }`.
|
||||||
|
|
||||||
|
### Graph
|
||||||
|
|
||||||
|
**Full graph topology:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /notes/graph
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodes": [{ "id": "uuid", "title": "Note Title" }],
|
||||||
|
"edges": [{ "source": "uuid", "target": "uuid" }],
|
||||||
|
"unresolved": [{ "source": "uuid", "target_title": "Missing Note" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Edges are directed: source contains `[[target_title]]`. Unresolved
|
||||||
|
edges have a `target_title` but no matching note (dangling wikilink).
|
||||||
|
When a note with that title is created, the edge auto-resolves.
|
||||||
|
|
||||||
|
**Backlinks** (who links to this note):
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /notes/:id/backlinks
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "data": [note objects] }` — all notes containing
|
||||||
|
`[[this note's title]]`.
|
||||||
|
|
||||||
|
### Folders
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /notes/folders
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "folders": ["work", "personal", "archive"] }` — distinct
|
||||||
|
folder values across all user notes.
|
||||||
|
|
||||||
|
### Bulk Operations
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /notes/bulk-delete
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ids": ["uuid-1", "uuid-2"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
44
docs/ICD/notifications.md
Normal file
44
docs/ICD/notifications.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Notifications
|
||||||
|
|
||||||
|
Real-time notification infrastructure with WebSocket delivery and
|
||||||
|
optional email transport.
|
||||||
|
|
||||||
|
### CRUD
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /notifications → paginated list
|
||||||
|
GET /notifications/unread-count → { "count": 5 }
|
||||||
|
PATCH /notifications/:id/read → mark one as read
|
||||||
|
POST /notifications/mark-all-read → mark all as read
|
||||||
|
DELETE /notifications/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Notification object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"user_id": "uuid",
|
||||||
|
"type": "role.fallback|memory.extracted|system.announcement|...",
|
||||||
|
"title": "Role Fallback Triggered",
|
||||||
|
"body": "Primary model unavailable, using fallback",
|
||||||
|
"metadata": {},
|
||||||
|
"read": false,
|
||||||
|
"created_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preferences
|
||||||
|
|
||||||
|
Users control per-type delivery:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /notifications/preferences → { "preferences": [...] }
|
||||||
|
PUT /notifications/preferences/:type ← { "in_app": true, "email": false }
|
||||||
|
DELETE /notifications/preferences/:type → reset to default
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolution: specific type pref → user wildcard `*` pref → system
|
||||||
|
default (`in_app=true`, `email=false`).
|
||||||
|
|
||||||
|
---
|
||||||
204
docs/ICD/personas.md
Normal file
204
docs/ICD/personas.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# Personas
|
||||||
|
|
||||||
|
A **Persona** is the unit of access control and AI identity: system
|
||||||
|
prompt + model + provider config + KB bindings + grant scoping. Users
|
||||||
|
interact with Personas, not raw provider configs. Admins curate which
|
||||||
|
Personas are available; team admins create team-scoped Personas.
|
||||||
|
|
||||||
|
**Model inheritance:** A Persona inherits all properties of its
|
||||||
|
underlying model. This includes context window size, max output
|
||||||
|
tokens, supported capabilities (thinking, tools, vision, streaming),
|
||||||
|
input/output pricing, and provider status. When the frontend displays
|
||||||
|
a Persona, it resolves the underlying model's capabilities and shows
|
||||||
|
the same pills/badges (e.g. thinking, tools, 200k context) that the
|
||||||
|
raw model would show. The Persona adds identity (name, avatar, system
|
||||||
|
prompt, KB bindings) on top of the model's capabilities — it never
|
||||||
|
masks or reduces them.
|
||||||
|
|
||||||
|
Three scopes, three route namespaces, one domain:
|
||||||
|
|
||||||
|
| Scope | Routes | Who manages |
|
||||||
|
|-------|--------|-------------|
|
||||||
|
| Personal | `GET/POST/PUT/DELETE /personas` | The user |
|
||||||
|
| Team | `GET/POST/PUT/DELETE /teams/:teamId/personas` | Team admin |
|
||||||
|
| Global (admin) | `GET/POST/PUT/DELETE /admin/personas` | Platform admin |
|
||||||
|
|
||||||
|
All three return the same Persona object shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "Code Reviewer",
|
||||||
|
"description": "Reviews code for bugs and style",
|
||||||
|
"system_prompt": "You are a senior code reviewer...",
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
|
"provider_config_id": "uuid|null",
|
||||||
|
"scope": "personal|team|global",
|
||||||
|
"owner_id": "uuid|null",
|
||||||
|
"is_default": false,
|
||||||
|
"is_active": true,
|
||||||
|
"avatar_url": "/api/v1/personas/uuid/avatar?v=123",
|
||||||
|
"inherited": {
|
||||||
|
"context_window": 200000,
|
||||||
|
"max_output_tokens": 8192,
|
||||||
|
"supports_vision": true,
|
||||||
|
"supports_tools": true,
|
||||||
|
"supports_thinking": true,
|
||||||
|
"supports_streaming": true,
|
||||||
|
"input_price_per_m": 3.00,
|
||||||
|
"output_price_per_m": 15.00,
|
||||||
|
"provider_status": "healthy|degraded|down|null"
|
||||||
|
},
|
||||||
|
"created_at": "...",
|
||||||
|
"updated_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `inherited` block is populated by the backend from the resolved
|
||||||
|
model capabilities at response time — it is not stored on the persona.
|
||||||
|
If the underlying model's capabilities change (e.g. provider upgrades
|
||||||
|
context window), the persona inherits the new values automatically.
|
||||||
|
The frontend uses `inherited` to render capability pills and context
|
||||||
|
size badges identical to the raw model.
|
||||||
|
|
||||||
|
### Personal Personas
|
||||||
|
|
||||||
|
Gated by `allow_user_personas` policy.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /personas → { "personas": [...] }
|
||||||
|
POST /personas ← { "name", "description", "system_prompt", "model", ... }
|
||||||
|
PUT /personas/:id ← partial update
|
||||||
|
DELETE /personas/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Team Personas
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /teams/:teamId/personas → { "personas": [...] }
|
||||||
|
POST /teams/:teamId/personas ← same shape
|
||||||
|
PUT /teams/:teamId/personas/:id
|
||||||
|
DELETE /teams/:teamId/personas/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin (Global) Personas
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/personas → { "personas": [...] }
|
||||||
|
POST /admin/personas ← same shape
|
||||||
|
PUT /admin/personas/:id
|
||||||
|
DELETE /admin/personas/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Persona KB Bindings
|
||||||
|
|
||||||
|
A Persona can have Knowledge Bases bound to it. When a channel uses
|
||||||
|
that Persona, the bound KBs are automatically scoped into `kb_search`
|
||||||
|
tool calls. Users never see or manage the underlying KBs directly —
|
||||||
|
they talk to the Persona.
|
||||||
|
|
||||||
|
**Get bindings:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /personas/:id/knowledge-bases → { "data": [KB objects] }
|
||||||
|
GET /teams/:teamId/personas/:id/knowledge-bases → { "data": [...] }
|
||||||
|
GET /admin/personas/:id/knowledge-bases → { "data": [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Set bindings** (replace all):
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /personas/:id/knowledge-bases
|
||||||
|
PUT /teams/:teamId/personas/:id/knowledge-bases
|
||||||
|
PUT /admin/personas/:id/knowledge-bases
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kb_ids": ["kb-uuid-1", "kb-uuid-2"],
|
||||||
|
"auto_search": {
|
||||||
|
"kb-uuid-1": true,
|
||||||
|
"kb-uuid-2": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`auto_search`: when `true`, the KB is always searched (prepended to
|
||||||
|
context). When `false`, it's available via the `kb_search` tool but
|
||||||
|
not auto-injected.
|
||||||
|
|
||||||
|
### Persona Avatars
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /personas/:id/avatar ← multipart/form-data (field: "avatar")
|
||||||
|
DELETE /personas/:id/avatar
|
||||||
|
POST /admin/personas/:id/avatar
|
||||||
|
DELETE /admin/personas/:id/avatar
|
||||||
|
```
|
||||||
|
|
||||||
|
### Persona Tool Grants
|
||||||
|
|
||||||
|
Control which tools a persona can use during completions. The completion
|
||||||
|
handler applies tool grants as a second-pass allowlist.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /personas/:id/tool-grants → { "grants": ["web_search", "kb_search", ...] }
|
||||||
|
PUT /personas/:id/tool-grants ← { "tool_ids": ["web_search", "calculator"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Admin equivalents:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/personas/:id/tool-grants
|
||||||
|
PUT /admin/personas/:id/tool-grants
|
||||||
|
```
|
||||||
|
|
||||||
|
When a workflow version is published, persona tool grants at that moment
|
||||||
|
are frozen into the version snapshot.
|
||||||
|
|
||||||
|
### Persona Groups
|
||||||
|
|
||||||
|
Saved roster templates. A persona group is a named set of personas that
|
||||||
|
can be stamped onto new conversations.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /persona-groups → { "data": [...] }
|
||||||
|
POST /persona-groups ← { "name", "description", "scope", "team_id" }
|
||||||
|
GET /persona-groups/:id → group with members
|
||||||
|
PUT /persona-groups/:id ← partial update
|
||||||
|
DELETE /persona-groups/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Members:**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /persona-groups/:id/members ← { "persona_id", "is_leader": false, "sort_order": 0 }
|
||||||
|
DELETE /persona-groups/:id/members/:memberId
|
||||||
|
```
|
||||||
|
|
||||||
|
`is_leader`: the leader persona responds when no @mention is present in
|
||||||
|
a group channel. Only one leader per group.
|
||||||
|
|
||||||
|
**Persona Group Object:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "Support Team",
|
||||||
|
"description": "...",
|
||||||
|
"owner_id": "uuid",
|
||||||
|
"scope": "personal|team|global",
|
||||||
|
"team_id": "uuid|null",
|
||||||
|
"members": [
|
||||||
|
{ "id": "uuid", "persona_id": "uuid", "is_leader": true, "sort_order": 0 }
|
||||||
|
],
|
||||||
|
"created_at": "...",
|
||||||
|
"updated_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resource Grants
|
||||||
|
|
||||||
|
See [teams.md](teams.md) §15.3 for the grant system. Personas use grants
|
||||||
|
to control who can see and use them beyond their base scope.
|
||||||
|
|
||||||
55
docs/ICD/profile.md
Normal file
55
docs/ICD/profile.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# User Profile & Settings
|
||||||
|
|
||||||
|
### Profile
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /profile → profileResponse
|
||||||
|
PUT /profile ← { "display_name", "email" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"username": "jdoe",
|
||||||
|
"email": "jdoe@example.com",
|
||||||
|
"display_name": "Jane Doe",
|
||||||
|
"role": "user|admin",
|
||||||
|
"avatar_url": "...",
|
||||||
|
"created_at": "...",
|
||||||
|
"last_login": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avatar
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /profile/avatar ← multipart/form-data (field: "avatar")
|
||||||
|
DELETE /profile/avatar
|
||||||
|
```
|
||||||
|
|
||||||
|
### Password
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /profile/password
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"current_password": "...",
|
||||||
|
"new_password": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
On password change, the UEK is re-wrapped with the new password
|
||||||
|
(BYOK keys remain accessible without re-encryption).
|
||||||
|
|
||||||
|
### App Settings
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /settings → { "settings": {...} }
|
||||||
|
PUT /settings ← { "theme", "editor_keybindings", ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
User-level preferences (theme, keybindings, default model, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
87
docs/ICD/projects.md
Normal file
87
docs/ICD/projects.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Projects
|
||||||
|
|
||||||
|
**Projects** are organizational containers that group channels,
|
||||||
|
knowledge bases, notes, and files. They follow the scope model
|
||||||
|
(personal, team, global).
|
||||||
|
|
||||||
|
### Project CRUD
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /projects → { "data": [...] }
|
||||||
|
POST /projects ← { "name", "description", "scope", "team_id", "persona_id", "system_prompt" }
|
||||||
|
GET /projects/:id → project object
|
||||||
|
PUT /projects/:id ← partial update
|
||||||
|
DELETE /projects/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Project object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "Q3 Research",
|
||||||
|
"description": "...",
|
||||||
|
"scope": "personal|team|global",
|
||||||
|
"owner_id": "uuid",
|
||||||
|
"team_id": "uuid|null",
|
||||||
|
"persona_id": "uuid|null",
|
||||||
|
"system_prompt": "...|null",
|
||||||
|
"is_archived": false,
|
||||||
|
"channel_count": 5,
|
||||||
|
"created_at": "...",
|
||||||
|
"updated_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Channel Association
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /projects/:id/channels → { "data": [channel objects] }
|
||||||
|
POST /projects/:id/channels ← { "channel_id": "uuid" }
|
||||||
|
DELETE /projects/:id/channels/:channelId
|
||||||
|
PUT /projects/:id/channels/reorder ← { "channel_ids": ["uuid1", "uuid2"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
A channel can only belong to one project. `POST` performs an atomic
|
||||||
|
move if the channel is already in a different project.
|
||||||
|
|
||||||
|
### KB Association
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /projects/:id/knowledge-bases → { "data": [KB objects] }
|
||||||
|
POST /projects/:id/knowledge-bases ← { "kb_id": "uuid" }
|
||||||
|
DELETE /projects/:id/knowledge-bases/:kbId
|
||||||
|
```
|
||||||
|
|
||||||
|
KBs bound to a project are automatically available to every channel
|
||||||
|
in that project (see §5.7 resolution chain).
|
||||||
|
|
||||||
|
### Note Association
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /projects/:id/notes → { "data": [note objects] }
|
||||||
|
POST /projects/:id/notes ← { "note_id": "uuid" }
|
||||||
|
DELETE /projects/:id/notes/:noteId
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Files
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /projects/:id/files → { "files": [...] }
|
||||||
|
POST /projects/:id/files ← multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
Project-level file uploads (distinct from workspace files and channel
|
||||||
|
attachments).
|
||||||
|
|
||||||
|
### Admin Project Management
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/projects → { "data": [...] }
|
||||||
|
DELETE /admin/projects/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Cross-instance visibility for platform admins. BYOK personal projects
|
||||||
|
remain private (admin can delete but not read content).
|
||||||
|
|
||||||
|
---
|
||||||
221
docs/ICD/providers.md
Normal file
221
docs/ICD/providers.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# Providers & Routing
|
||||||
|
|
||||||
|
The **multi-provider** system. One or more LLM providers are configured,
|
||||||
|
each with their own API keys, endpoints, and model catalogs. The routing
|
||||||
|
layer decides which provider handles each request.
|
||||||
|
|
||||||
|
### User BYOK Provider Configs
|
||||||
|
|
||||||
|
Gated by `allow_user_byok` policy. Personal API keys are encrypted with
|
||||||
|
the user's UEK (per-user encryption key, Argon2id-derived). Platform
|
||||||
|
admins cannot recover personal keys.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api-configs → { "configs": [safeConfig objects] }
|
||||||
|
POST /api-configs ← { "name", "provider", "endpoint", "api_key", ... }
|
||||||
|
GET /api-configs/:id → safeConfig
|
||||||
|
PUT /api-configs/:id ← partial update (api_key optional)
|
||||||
|
DELETE /api-configs/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
`safeConfig` — API keys are **never** returned:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "My OpenAI",
|
||||||
|
"provider": "openai",
|
||||||
|
"endpoint": "https://api.openai.com/v1",
|
||||||
|
"model_default": "gpt-4o",
|
||||||
|
"scope": "personal",
|
||||||
|
"owner_id": "uuid",
|
||||||
|
"is_active": true,
|
||||||
|
"has_key": true,
|
||||||
|
"config": {},
|
||||||
|
"headers": {},
|
||||||
|
"settings": {},
|
||||||
|
"created_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**List models for a user config:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api-configs/:id/models
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "models": [catalog entries] }`.
|
||||||
|
|
||||||
|
**Fetch/sync models from provider API:**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api-configs/:id/models/fetch
|
||||||
|
```
|
||||||
|
|
||||||
|
Calls the provider's model list API, upserts into the local catalog.
|
||||||
|
|
||||||
|
### Admin Global Provider Configs
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/configs → { "configs": [configWithKey objects] }
|
||||||
|
POST /admin/configs ← { "name", "provider", "endpoint", "api_key", "config", "headers", "settings", "is_private" }
|
||||||
|
PUT /admin/configs/:id ← partial update
|
||||||
|
DELETE /admin/configs/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
`configWithKey` is the same as `safeConfig` but comes from
|
||||||
|
`ListGlobal` — still redacts API keys, just adds the `has_key` flag.
|
||||||
|
|
||||||
|
Admin configs use the `ENCRYPTION_KEY` env var (not per-user UEK).
|
||||||
|
`is_private`: when true, the config is available for admin-created
|
||||||
|
Personas but not directly selectable by users.
|
||||||
|
|
||||||
|
### Model Catalog (Admin)
|
||||||
|
|
||||||
|
The catalog is populated by fetching from provider APIs and stores
|
||||||
|
model metadata (capabilities, context window, pricing).
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/models → { "models": [catalog entries] }
|
||||||
|
PUT /admin/models/:id ← { "visibility", "display_name", ... }
|
||||||
|
PUT /admin/models/bulk ← { "provider_config_id", "visibility" }
|
||||||
|
DELETE /admin/models/:id
|
||||||
|
POST /admin/models/fetch ← { "provider_config_id": "uuid|empty" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fetch** with empty `provider_config_id` syncs ALL active global
|
||||||
|
providers. Returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "models synced",
|
||||||
|
"added": 5,
|
||||||
|
"updated": 12,
|
||||||
|
"total": 47
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or for multi-provider fetch: `{ "added", "updated", "total", "errors": [...] }`.
|
||||||
|
|
||||||
|
**Bulk visibility** sets all models for a provider (or all models
|
||||||
|
globally if no `provider_config_id`) to the specified visibility
|
||||||
|
(`enabled`, `disabled`, `team`).
|
||||||
|
|
||||||
|
### Provider Health
|
||||||
|
|
||||||
|
Real-time health tracking per provider config. The health accumulator
|
||||||
|
records success/failure/latency on every completion, flushes to DB
|
||||||
|
every 60 seconds.
|
||||||
|
|
||||||
|
**Get all:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/providers/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "data": [ProviderHealth objects] }`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"provider_config_id": "uuid",
|
||||||
|
"provider_config_name": "OpenAI Production",
|
||||||
|
"status": "healthy|degraded|down",
|
||||||
|
"error_rate": 0.02,
|
||||||
|
"avg_latency_ms": 450,
|
||||||
|
"timeout_rate": 0.01,
|
||||||
|
"rate_limit_count": 3,
|
||||||
|
"last_check": "...",
|
||||||
|
"last_error": "...|null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Get single:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/providers/:id/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auto-disable:** After `PROVIDER_AUTO_DISABLE_THRESHOLD` consecutive
|
||||||
|
"down" windows (default: 3), the provider is automatically deactivated.
|
||||||
|
|
||||||
|
### Capability Overrides
|
||||||
|
|
||||||
|
Admin can override any model capability detected by the catalog or
|
||||||
|
heuristic layer.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/capability-overrides → { "data": [...] }
|
||||||
|
GET /admin/models/:id/capabilities → capabilities for one model
|
||||||
|
PUT /admin/models/:id/capabilities ← { "field": "value" }
|
||||||
|
DELETE /admin/models/:id/capabilities/:overrideId
|
||||||
|
```
|
||||||
|
|
||||||
|
Override fields: `supports_vision`, `supports_tools`, `supports_thinking`,
|
||||||
|
`context_window`, `max_output_tokens`, etc.
|
||||||
|
|
||||||
|
### Routing Policies
|
||||||
|
|
||||||
|
Policy-based request routing. Evaluated after model/config resolution,
|
||||||
|
before provider dispatch.
|
||||||
|
|
||||||
|
**Admin CRUD:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/routing/policies → { "data": [...] }
|
||||||
|
GET /admin/routing/policies/:id → policy object
|
||||||
|
POST /admin/routing/policies ← { "name", "scope", "team_id", "priority", "policy_type", "config", "is_active" }
|
||||||
|
PUT /admin/routing/policies/:id
|
||||||
|
DELETE /admin/routing/policies/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Policy object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "Prefer Anthropic",
|
||||||
|
"scope": "global|team",
|
||||||
|
"team_id": "uuid|null",
|
||||||
|
"priority": 10,
|
||||||
|
"policy_type": "provider_prefer|team_route|cost_limit|model_alias|capability_match",
|
||||||
|
"config": {},
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Policy type | Config | Behavior |
|
||||||
|
|------------|--------|----------|
|
||||||
|
| `provider_prefer` | `{ "providers": ["cfg-1", "cfg-2"] }` | Ordered fallback list |
|
||||||
|
| `team_route` | `{ "providers": ["cfg-1"] }` | Restrict team to specific providers |
|
||||||
|
| `cost_limit` | `{ "max_cost_per_request": 0.50 }` | Heuristic cost cap |
|
||||||
|
| `model_alias` | `{ "alias": "fast", "target_model": "...", "target_config": "..." }` | Alias → provider+model rewrite |
|
||||||
|
| `capability_match` | `{ "require": ["tool_calling"], "prefer": "cheapest" }` | Match cheapest model with required capabilities |
|
||||||
|
|
||||||
|
**Dry-run test:**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /admin/routing/test
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
|
"user_id": "uuid",
|
||||||
|
"team_id": "uuid|null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the ranked candidate list with health status for each.
|
||||||
|
|
||||||
|
### Provider Types
|
||||||
|
|
||||||
|
Registry of supported provider types with metadata.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/provider-types
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "types": [...] }` with name, display name, default endpoint,
|
||||||
|
profile schema, and supported features per type.
|
||||||
|
|
||||||
|
---
|
||||||
118
docs/ICD/surfaces.md
Normal file
118
docs/ICD/surfaces.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Surfaces
|
||||||
|
|
||||||
|
Dynamic surface lifecycle. Core surfaces are registered at startup.
|
||||||
|
Extension surfaces are uploaded by admins and served from the
|
||||||
|
`surface_registry` table.
|
||||||
|
|
||||||
|
## User Endpoints
|
||||||
|
|
||||||
|
### List Enabled Surfaces
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /surfaces
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns surfaces the user can access (enabled in registry).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"surfaces": [
|
||||||
|
{ "id": "chat", "title": "Chat", "source": "core", "enabled": true },
|
||||||
|
{ "id": "editor", "title": "Editor", "source": "core", "enabled": true },
|
||||||
|
{ "id": "hello-dashboard", "title": "Hello Dashboard", "source": "extension", "enabled": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auth:** Authenticated.
|
||||||
|
|
||||||
|
## Admin Endpoints
|
||||||
|
|
||||||
|
### List All Surfaces
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/surfaces
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns all surfaces including disabled ones.
|
||||||
|
|
||||||
|
**Auth:** Platform admin.
|
||||||
|
|
||||||
|
### Get Surface
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/surfaces/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns full surface details including manifest.
|
||||||
|
|
||||||
|
### Install Surface
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /admin/surfaces/install
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
Field: `archive` — a `.surface` archive (tar.gz containing `manifest.json`
|
||||||
|
+ static assets). The manifest declares `id`, `title`, `route`, required
|
||||||
|
data loaders, scripts, and CSS.
|
||||||
|
|
||||||
|
**Auth:** Platform admin.
|
||||||
|
|
||||||
|
### Enable / Disable
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /admin/surfaces/:id/enable
|
||||||
|
PUT /admin/surfaces/:id/disable
|
||||||
|
```
|
||||||
|
|
||||||
|
Disabled surfaces redirect to `/` and hide from navigation.
|
||||||
|
|
||||||
|
**Auth:** Platform admin.
|
||||||
|
|
||||||
|
### Delete Surface
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /admin/surfaces/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Removes the surface and its static assets. Core surfaces cannot be deleted.
|
||||||
|
|
||||||
|
**Auth:** Platform admin.
|
||||||
|
|
||||||
|
## Surface Registry Object
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "hello-dashboard",
|
||||||
|
"title": "Hello Dashboard",
|
||||||
|
"manifest": { "route": "/s/hello-dashboard", "scripts": [...], "css": [...] },
|
||||||
|
"enabled": true,
|
||||||
|
"source": "core|extension",
|
||||||
|
"installed_at": "...",
|
||||||
|
"updated_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Page Routes
|
||||||
|
|
||||||
|
Extension surfaces are served at `/s/:slug` via `RenderExtensionSurface()`.
|
||||||
|
The page engine does a runtime DB lookup — no server restart needed after install.
|
||||||
|
|
||||||
|
Static assets are served from `/surfaces/:id/` (nginx location block in
|
||||||
|
both unified and split deployment).
|
||||||
|
|
||||||
|
## Surface Archive Format
|
||||||
|
|
||||||
|
A `.surface` file is a tar.gz containing:
|
||||||
|
|
||||||
|
```
|
||||||
|
manifest.json — required
|
||||||
|
script.js — optional
|
||||||
|
style.css — optional
|
||||||
|
assets/ — optional (icons, images, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
See [EXTENSION-SURFACES.md](../EXTENSION-SURFACES.md) for the full
|
||||||
|
authoring guide including manifest schema, platform API, and CSS
|
||||||
|
custom properties.
|
||||||
290
docs/ICD/tasks.md
Normal file
290
docs/ICD/tasks.md
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
Autonomous agent scheduling. A task is a cron-driven or one-shot prompt
|
||||||
|
execution in a headless service channel — no human participant in the loop.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Global config keys (set via `PUT /admin/settings/:key`):
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `tasks.enabled` | `true` | Master kill switch for task scheduler |
|
||||||
|
| `tasks.allow_personal` | `true` | Allow non-admin users to create tasks |
|
||||||
|
| `tasks.max_concurrent` | `5` | Max tasks running simultaneously |
|
||||||
|
| `tasks.personal_require_byok` | `false` | Personal tasks must use BYOK provider |
|
||||||
|
|
||||||
|
Default budget ceilings (overridable per task):
|
||||||
|
|
||||||
|
| Key | Default |
|
||||||
|
|-----|---------|
|
||||||
|
| `tasks.default_max_tokens` | `4096` |
|
||||||
|
| `tasks.default_max_tool_calls` | `10` |
|
||||||
|
| `tasks.default_max_wall_clock` | `300` (seconds) |
|
||||||
|
|
||||||
|
## Personal Tasks
|
||||||
|
|
||||||
|
### List My Tasks
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns tasks owned by the current user.
|
||||||
|
|
||||||
|
**Auth:** Authenticated.
|
||||||
|
|
||||||
|
### Create Task
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Morning News Digest",
|
||||||
|
"description": "Summarize top tech news",
|
||||||
|
"task_type": "prompt",
|
||||||
|
"persona_id": "uuid|null",
|
||||||
|
"model_id": "claude-sonnet-4-20250514",
|
||||||
|
"system_prompt": "You are a news summarizer.",
|
||||||
|
"user_prompt": "Summarize the top 5 tech news stories from today.",
|
||||||
|
"schedule": "@daily",
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"max_tool_calls": 10,
|
||||||
|
"max_wall_clock": 300,
|
||||||
|
"output_mode": "channel",
|
||||||
|
"webhook_url": "https://...",
|
||||||
|
"provider_config_id": "uuid|null",
|
||||||
|
"notify_on_complete": false,
|
||||||
|
"notify_on_failure": true,
|
||||||
|
"tool_grants": ["web_search", "url_fetch"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`task_type`: `prompt` (direct LLM execution) or `workflow` (instantiate
|
||||||
|
a workflow in the service channel).
|
||||||
|
|
||||||
|
`schedule`: cron expression or `once`. Supported patterns: `@hourly`,
|
||||||
|
`@daily`, `@weekly`, `*/N * * * *` (every N minutes), full 5-field cron
|
||||||
|
via `robfig/cron/v3`.
|
||||||
|
|
||||||
|
`output_mode`: `channel` (default — output stays in service channel),
|
||||||
|
`note` (creates a note from the output), `webhook` (POST result to URL).
|
||||||
|
|
||||||
|
**Auth:** `tasks.create` permission required.
|
||||||
|
|
||||||
|
### Get Task
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /tasks/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auth:** Owner or admin.
|
||||||
|
|
||||||
|
### Update Task
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /tasks/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Partial update. All fields from create are accepted.
|
||||||
|
|
||||||
|
**Auth:** `tasks.create` permission, must be owner.
|
||||||
|
|
||||||
|
### Delete Task
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /tasks/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auth:** `tasks.create` permission, must be owner.
|
||||||
|
|
||||||
|
### Run Now (Manual Trigger)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /tasks/:id/run
|
||||||
|
```
|
||||||
|
|
||||||
|
Immediately executes the task regardless of schedule. Creates a new
|
||||||
|
task run. Returns 409 if a run is already active.
|
||||||
|
|
||||||
|
**Auth:** `tasks.create` permission, must be owner.
|
||||||
|
|
||||||
|
### Kill Active Run
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /tasks/:id/kill
|
||||||
|
```
|
||||||
|
|
||||||
|
Cancels the active run. Sets status to `cancelled`.
|
||||||
|
|
||||||
|
**Auth:** `tasks.create` permission, must be owner.
|
||||||
|
|
||||||
|
### List Runs
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /tasks/:id/runs
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns run history for the task, most recent first.
|
||||||
|
|
||||||
|
**Auth:** Owner or admin.
|
||||||
|
|
||||||
|
## Team Tasks
|
||||||
|
|
||||||
|
Team-scoped tasks visible to all team members, manageable by team admins.
|
||||||
|
|
||||||
|
### List Team Tasks (Member)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /teams/:teamId/tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns tasks scoped to the team. Read-only for members.
|
||||||
|
|
||||||
|
**Auth:** Team member.
|
||||||
|
|
||||||
|
### List Team Task Runs (Member)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /teams/:teamId/tasks/:id/runs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auth:** Team member.
|
||||||
|
|
||||||
|
### Create Team Task (Admin)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /teams/:teamId/tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
Same shape as personal task creation. Team scope is injected automatically.
|
||||||
|
|
||||||
|
**Auth:** `tasks.create` permission, team admin.
|
||||||
|
|
||||||
|
### Update/Delete/Run/Kill Team Task
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /teams/:teamId/tasks/:id
|
||||||
|
DELETE /teams/:teamId/tasks/:id
|
||||||
|
POST /teams/:teamId/tasks/:id/run
|
||||||
|
POST /teams/:teamId/tasks/:id/kill
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auth:** `tasks.create` permission, team admin.
|
||||||
|
|
||||||
|
## Admin Tasks
|
||||||
|
|
||||||
|
Platform-wide task management.
|
||||||
|
|
||||||
|
### List All Tasks
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns all tasks across all users and teams.
|
||||||
|
|
||||||
|
**Auth:** Platform admin.
|
||||||
|
|
||||||
|
### Admin Run/Kill/Delete
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /admin/tasks/:id/run
|
||||||
|
POST /admin/tasks/:id/kill
|
||||||
|
DELETE /admin/tasks/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auth:** Platform admin.
|
||||||
|
|
||||||
|
## Task Object
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"owner_id": "uuid",
|
||||||
|
"team_id": "uuid|null",
|
||||||
|
"name": "Morning News Digest",
|
||||||
|
"description": "...",
|
||||||
|
"scope": "personal|team|global",
|
||||||
|
"task_type": "prompt|workflow",
|
||||||
|
"persona_id": "uuid|null",
|
||||||
|
"model_id": "claude-sonnet-4-20250514",
|
||||||
|
"system_prompt": "...",
|
||||||
|
"user_prompt": "...",
|
||||||
|
"workflow_id": "uuid|null",
|
||||||
|
"tool_grants": ["web_search", "url_fetch"],
|
||||||
|
"schedule": "@daily",
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
"is_active": true,
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"max_tool_calls": 10,
|
||||||
|
"max_wall_clock": 300,
|
||||||
|
"output_mode": "channel|note|webhook",
|
||||||
|
"output_channel_id": "uuid|null",
|
||||||
|
"webhook_url": "https://...",
|
||||||
|
"webhook_secret": "...",
|
||||||
|
"provider_config_id": "uuid|null",
|
||||||
|
"notify_on_complete": false,
|
||||||
|
"notify_on_failure": true,
|
||||||
|
"last_run_at": "...|null",
|
||||||
|
"next_run_at": "...|null",
|
||||||
|
"run_count": 42,
|
||||||
|
"created_at": "...",
|
||||||
|
"updated_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task Run Object
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"task_id": "uuid",
|
||||||
|
"channel_id": "uuid|null",
|
||||||
|
"status": "running|completed|failed|budget_exceeded|cancelled",
|
||||||
|
"started_at": "...",
|
||||||
|
"completed_at": "...|null",
|
||||||
|
"tokens_used": 1234,
|
||||||
|
"tool_calls": 3,
|
||||||
|
"wall_clock": 45,
|
||||||
|
"error": "...|null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
The `TaskScheduler` is a background goroutine polling every 30 seconds.
|
||||||
|
|
||||||
|
1. Finds tasks where `next_run_at <= now` and `is_active = true`
|
||||||
|
2. Skips if an active run exists (`GetActiveRun` returns non-nil)
|
||||||
|
3. Creates or reuses a `service` channel (`output_channel_id`)
|
||||||
|
4. Persists the `user_prompt` as a message in the service channel
|
||||||
|
5. Runs `CoreToolLoop` (headless completion — same as streaming but no SSE)
|
||||||
|
6. Enforces budgets: `max_tokens`, `max_tool_calls`, `max_wall_clock`
|
||||||
|
7. On budget breach: status = `budget_exceeded`, owner notified
|
||||||
|
8. On completion: updates `last_run_at`, calculates `next_run_at`
|
||||||
|
|
||||||
|
**Provider resolution:** BYOK → team provider → global provider → routing
|
||||||
|
policy. If `tasks.personal_require_byok` is true, personal tasks that
|
||||||
|
don't have a BYOK provider fail at step 5.
|
||||||
|
|
||||||
|
## Webhooks
|
||||||
|
|
||||||
|
Tasks with `webhook_url` fire a POST on completion:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": "uuid",
|
||||||
|
"run_id": "uuid",
|
||||||
|
"status": "completed|failed|budget_exceeded",
|
||||||
|
"output": "...",
|
||||||
|
"tokens_used": 1234,
|
||||||
|
"timestamp": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Signed with HMAC-SHA256 using `webhook_secret` in the
|
||||||
|
`X-Switchboard-Signature` header. Retry: 3 attempts, exponential backoff.
|
||||||
177
docs/ICD/teams.md
Normal file
177
docs/ICD/teams.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Teams & Access Control
|
||||||
|
|
||||||
|
### My Teams
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /teams/mine → { "teams": [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns teams the current user is a member of.
|
||||||
|
|
||||||
|
### Team Administration
|
||||||
|
|
||||||
|
Team-admin-scoped routes (require `RequireTeamAdmin` middleware):
|
||||||
|
|
||||||
|
**Team CRUD (platform admin):**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/teams → { "teams": [...] }
|
||||||
|
POST /admin/teams
|
||||||
|
GET /admin/teams/:id
|
||||||
|
PUT /admin/teams/:id
|
||||||
|
DELETE /admin/teams/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Members:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/teams/:id/members → { "members": [...] }
|
||||||
|
POST /admin/teams/:id/members ← { "user_id", "role" }
|
||||||
|
PUT /admin/teams/:id/members/:memberId ← { "role" }
|
||||||
|
DELETE /admin/teams/:id/members/:memberId
|
||||||
|
```
|
||||||
|
|
||||||
|
**Team-scoped routes** (team admin, not platform admin):
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /teams/:teamId/members
|
||||||
|
POST /teams/:teamId/members ← { "user_id", "role" }
|
||||||
|
PUT /teams/:teamId/members/:memberId
|
||||||
|
DELETE /teams/:teamId/members/:memberId
|
||||||
|
```
|
||||||
|
|
||||||
|
**Team Providers:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /teams/:teamId/providers → { "configs": [...] }
|
||||||
|
POST /teams/:teamId/providers ← { "name", "provider", "endpoint", "api_key", ... }
|
||||||
|
PUT /teams/:teamId/providers/:id
|
||||||
|
DELETE /teams/:teamId/providers/:id
|
||||||
|
GET /teams/:teamId/providers/:id/models → { "models": [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Team Models:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /teams/:teamId/models → { "models": [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Available models for this team (global + team-scoped).
|
||||||
|
|
||||||
|
**Team Personas:** See §4.2.
|
||||||
|
|
||||||
|
**Team Roles:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /teams/:teamId/roles → { "roles": [...] }
|
||||||
|
PUT /teams/:teamId/roles/:role ← { "permissions": {...} }
|
||||||
|
DELETE /teams/:teamId/roles/:role
|
||||||
|
```
|
||||||
|
|
||||||
|
**Team Audit:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /teams/:teamId/audit → paginated audit log
|
||||||
|
GET /teams/:teamId/audit/actions → { "actions": [...] } (distinct action types)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Team Usage:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /teams/:teamId/usage → { "totals": {...}, "results": [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Groups & Resource Grants
|
||||||
|
|
||||||
|
Groups are ACL containers that decouple access from team membership.
|
||||||
|
A resource grant controls who can see a Persona or KB beyond its base
|
||||||
|
scope.
|
||||||
|
|
||||||
|
**My Groups:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /groups/mine → { "groups": [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Admin Group CRUD:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/groups → { "groups": [...] }
|
||||||
|
POST /admin/groups ← { "name", "scope", "team_id" }
|
||||||
|
GET /admin/groups/:id
|
||||||
|
PUT /admin/groups/:id
|
||||||
|
DELETE /admin/groups/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Group Members:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/groups/:id/members → { "members": [...] }
|
||||||
|
POST /admin/groups/:id/members ← { "user_id" }
|
||||||
|
DELETE /admin/groups/:id/members/:userId
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resource Grants:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/grants/:type/:id → { "grant": {...} }
|
||||||
|
PUT /admin/grants/:type/:id ← { "grant_type": "team_only|global|groups", "group_ids": [...] }
|
||||||
|
DELETE /admin/grants/:type/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
`:type` is `persona` or `kb`. `:id` is the resource ID.
|
||||||
|
|
||||||
|
Grant types:
|
||||||
|
|
||||||
|
| `grant_type` | Visibility |
|
||||||
|
|--------------|-----------|
|
||||||
|
| `team_only` | Only the owning team |
|
||||||
|
| `global` | All authenticated users |
|
||||||
|
| `groups` | Members of specified groups |
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
|
||||||
|
Groups carry fine-grained permissions. Permission resolution:
|
||||||
|
union of all group permissions + Everyone group permissions.
|
||||||
|
|
||||||
|
**List all permissions:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/permissions → { "permissions": ["model.use", "kb.read", ...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Get user's effective permissions:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/users/:id/permissions → { "permissions": [...], "groups": [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the resolved permission set and contributing groups.
|
||||||
|
|
||||||
|
**Group permission fields** (set via `PUT /admin/groups/:id`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"permissions": ["model.use", "kb.read", "channel.create"],
|
||||||
|
"token_budget_daily": 100000,
|
||||||
|
"token_budget_monthly": 3000000,
|
||||||
|
"allowed_models": ["claude-sonnet-4-20250514", "gpt-4o"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `permissions` | string[] | Permission constants granted to members |
|
||||||
|
| `token_budget_daily` | int/null | Daily token ceiling (NULL = unlimited) |
|
||||||
|
| `token_budget_monthly` | int/null | Monthly token ceiling (NULL = unlimited) |
|
||||||
|
| `allowed_models` | string[]/null | Model allowlist (NULL = unrestricted) |
|
||||||
|
|
||||||
|
**Everyone group:** System-seeded group (ID `00000000-0000-0000-0000-000000000001`)
|
||||||
|
whose permissions apply to all authenticated users implicitly (no membership
|
||||||
|
row needed). Editable by admins, cannot be deleted (`source=system`).
|
||||||
|
|
||||||
|
**RequirePermission middleware:** Routes gated by permission check user's
|
||||||
|
resolved permission set. Returns 403 if the required permission is not present.
|
||||||
|
|
||||||
|
See [enums.md](enums.md) for the full permission constant list.
|
||||||
|
|
||||||
146
docs/ICD/utility.md
Normal file
146
docs/ICD/utility.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# Export & Utility
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /health → { "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Public, no auth. Also available at `/api/v1/health`.
|
||||||
|
|
||||||
|
### Export
|
||||||
|
|
||||||
|
Convert markdown to PDF or DOCX via pandoc:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /export
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": "# My Document\n\nBody text...",
|
||||||
|
"format": "pdf|docx",
|
||||||
|
"filename": "my-document"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the binary file with appropriate Content-Type. Requires
|
||||||
|
`pandoc` in the container (available in the unified and backend
|
||||||
|
Docker images).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17b. Files & Storage
|
||||||
|
|
||||||
|
All binary content in Chat Switchboard flows through a single
|
||||||
|
**ObjectStore** abstraction backed by PVC (filesystem) or S3. All
|
||||||
|
file metadata lives in one `files` table. The old `attachments`
|
||||||
|
table is dropped.
|
||||||
|
|
||||||
|
### Storage Backend
|
||||||
|
|
||||||
|
| Operation | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `Put(key, reader, size, contentType)` | Store a blob |
|
||||||
|
| `Get(key)` → reader, size, contentType | Retrieve a blob |
|
||||||
|
| `Delete(key)` | Remove a blob |
|
||||||
|
| `DeletePrefix(prefix)` | Bulk remove (channel deletion) |
|
||||||
|
| `Exists(key)` | Check without reading |
|
||||||
|
| `Healthy()` | Backend health check |
|
||||||
|
| `Stats()` | Aggregate metrics |
|
||||||
|
|
||||||
|
Backends: `pvc` (`STORAGE_PATH=/data/storage`) or `s3`
|
||||||
|
(`S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`).
|
||||||
|
|
||||||
|
### Key Namespacing
|
||||||
|
|
||||||
|
| Prefix | Pattern | What |
|
||||||
|
|--------|---------|------|
|
||||||
|
| `attachments/` | `attachments/{channel_id}/{file_id}_{filename}` | User uploads + AI outputs (legacy prefix retained for existing blobs) |
|
||||||
|
| `kb/` | `kb/{kb_id}/{doc_id}_{filename}` | Knowledge base documents |
|
||||||
|
| `exports/` | `exports/{user_id}/{export_id}_{filename}` | Chat/data exports |
|
||||||
|
| `workspace/` | `workspace/{workspace_id}/{path}` | Workspace files |
|
||||||
|
|
||||||
|
### File Object
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE files (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
|
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
|
||||||
|
origin TEXT NOT NULL DEFAULT 'user_upload'
|
||||||
|
CHECK (origin IN ('user_upload','tool_output','system')),
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||||
|
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
storage_key TEXT NOT NULL,
|
||||||
|
display_hint TEXT NOT NULL DEFAULT 'download'
|
||||||
|
CHECK (display_hint IN ('inline','download','thumbnail')),
|
||||||
|
extracted_text TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Origin:**
|
||||||
|
|
||||||
|
| Value | Who creates | Examples |
|
||||||
|
|-------|-------------|---------|
|
||||||
|
| `user_upload` | User via upload UI | PDF, image, doc attached to message |
|
||||||
|
| `tool_output` | Tool execution during completion | DALL-E image, video gen, code artifact |
|
||||||
|
| `system` | Platform | Export, avatar stored as file |
|
||||||
|
|
||||||
|
**Display hint:**
|
||||||
|
|
||||||
|
| Value | Frontend behavior |
|
||||||
|
|-------|-------------------|
|
||||||
|
| `inline` | Render in message stream (images, video, audio, HTML) |
|
||||||
|
| `download` | Filename + size + download button |
|
||||||
|
| `thumbnail` | Thumbnail preview, click to expand |
|
||||||
|
|
||||||
|
### Tool Output Flow
|
||||||
|
|
||||||
|
1. Tool executes (image gen, video render, etc.)
|
||||||
|
2. Tool writes blob via `ObjectStore.Put`
|
||||||
|
3. Tool creates file record: `POST /files` with `origin: "tool_output"`, `message_id` = current assistant message
|
||||||
|
4. Tool result includes `file_id` reference
|
||||||
|
5. WebSocket emits `file.created` event:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "file.created",
|
||||||
|
"channel_id": "uuid",
|
||||||
|
"message_id": "uuid",
|
||||||
|
"file": { ...file object... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Frontend renders inline based on `display_hint` + `content_type`
|
||||||
|
|
||||||
|
The event fires during streaming so the user sees the artifact
|
||||||
|
before the completion finishes.
|
||||||
|
|
||||||
|
### Content Type → Display Hint Defaults
|
||||||
|
|
||||||
|
| Content Type | Default Hint | Rendering |
|
||||||
|
|-------------|-------------|-----------|
|
||||||
|
| `image/*` | `inline` | `<img>` in message, lightbox on click |
|
||||||
|
| `video/*` | `inline` | `<video>` player |
|
||||||
|
| `audio/*` | `inline` | `<audio>` player |
|
||||||
|
| `text/html` | `inline` | Sandboxed iframe |
|
||||||
|
| `application/pdf` | `thumbnail` | Thumb + PDF viewer on click |
|
||||||
|
| `text/*`, `application/json` | `inline` | Syntax-highlighted code block |
|
||||||
|
| Everything else | `download` | Filename + download button |
|
||||||
|
|
||||||
|
Tools can override the default hint explicitly.
|
||||||
|
|
||||||
|
### File Manager (Future Surface)
|
||||||
|
|
||||||
|
A user-scoped file browser backed by `GET /files?page=1&per_page=50`.
|
||||||
|
Displays all files owned by the user across all channels, grouped by
|
||||||
|
channel or date. Supports download and delete. Deleting a file that
|
||||||
|
is referenced by a message replaces the inline render with a
|
||||||
|
"file deleted" placeholder.
|
||||||
219
docs/ICD/websocket.md
Normal file
219
docs/ICD/websocket.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# WebSocket Protocol
|
||||||
|
|
||||||
|
Real-time bidirectional event bus. Single connection per client.
|
||||||
|
|
||||||
|
### Connection
|
||||||
|
|
||||||
|
```
|
||||||
|
ws://{host}{BASE_PATH}/ws?token={access_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lifecycle:**
|
||||||
|
- Server sends `ping` every 54 seconds
|
||||||
|
- Client must respond with `pong` within 60 seconds or disconnection
|
||||||
|
- Max message size: 4 KB
|
||||||
|
- Write deadline: 10 seconds
|
||||||
|
|
||||||
|
### Event Envelope
|
||||||
|
|
||||||
|
All messages are JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "event.type",
|
||||||
|
"payload": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Room Subscription
|
||||||
|
|
||||||
|
Clients subscribe to rooms for scoped event delivery:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "subscribe", "payload": { "room": "channel:uuid" } }
|
||||||
|
{ "type": "unsubscribe", "payload": { "room": "channel:uuid" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Routing Table
|
||||||
|
|
||||||
|
| Prefix | Direction | Description |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| `channel.created` | → client | New channel created |
|
||||||
|
| `channel.updated` | → client | Channel metadata changed |
|
||||||
|
| `channel.deleted` | → client | Channel removed |
|
||||||
|
| `message.created` | → client | New message in subscribed channel |
|
||||||
|
| `message.updated` | → client | Message content changed |
|
||||||
|
| `message.deleted` | → client | Message removed |
|
||||||
|
| `cursor.updated` | → client | Branch cursor moved |
|
||||||
|
| `typing.start` | ↔ both | Participant started typing (payload includes `participant_id`, `participant_type`) |
|
||||||
|
| `typing.stop` | ↔ both | Typing ended |
|
||||||
|
| `participant.joined` | → client | Participant added to channel |
|
||||||
|
| `participant.left` | → client | Participant removed from channel |
|
||||||
|
| `participant.updated` | → client | Participant role changed |
|
||||||
|
| `presence.update` | → client | Participant online/idle/offline status change |
|
||||||
|
| `model.changed` | → client | Channel model roster changed |
|
||||||
|
| `persona.updated` | → client | Persona metadata changed |
|
||||||
|
| `kb.updated` | → client | Knowledge base changed |
|
||||||
|
| `kb.document.status` | → client | Document processing status update |
|
||||||
|
| `note.created` | → client | Note created |
|
||||||
|
| `note.updated` | → client | Note content changed |
|
||||||
|
| `note.deleted` | → client | Note removed |
|
||||||
|
| `notification.new` | → client | New notification |
|
||||||
|
| `notification.read` | → client | Notification marked read |
|
||||||
|
| `memory.extracted` | → client | New memory extracted |
|
||||||
|
| `memory.status` | → client | Memory approved/rejected |
|
||||||
|
| `role.fallback` | → client | Role fallback triggered |
|
||||||
|
| `workspace.updated` | → client | Workspace state changed |
|
||||||
|
| `project.updated` | → client | Project metadata changed |
|
||||||
|
| `extension.updated` | → client | Extension config changed |
|
||||||
|
| `settings.updated` | → client | Global settings changed |
|
||||||
|
| `user.updated` | → client | User profile changed |
|
||||||
|
| `file.created` | → client | File uploaded or generated (§17b.4) |
|
||||||
|
| `file.deleted` | → client | File removed |
|
||||||
|
|
||||||
|
Direction: `→ client` = server-to-client only, `← client` = client-to-server
|
||||||
|
only, `↔ both` = bidirectional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Appendix: Enums
|
||||||
|
|
||||||
|
### Channel Types
|
||||||
|
|
||||||
|
`direct` (single-user), `group` (multi-user/multi-model), `workflow` (staged)
|
||||||
|
|
||||||
|
### Participant Types
|
||||||
|
|
||||||
|
`user`, `persona`, `session`
|
||||||
|
|
||||||
|
### Participant Roles
|
||||||
|
|
||||||
|
`owner`, `member`, `observer`
|
||||||
|
|
||||||
|
### Presence Statuses
|
||||||
|
|
||||||
|
`online`, `idle`, `offline`
|
||||||
|
|
||||||
|
### Message Roles
|
||||||
|
|
||||||
|
`user`, `assistant`, `system`, `tool`
|
||||||
|
|
||||||
|
### Scopes
|
||||||
|
|
||||||
|
`personal`, `team`, `global`
|
||||||
|
|
||||||
|
### Visibility (model catalog)
|
||||||
|
|
||||||
|
`enabled`, `disabled`, `team`
|
||||||
|
|
||||||
|
### User Roles
|
||||||
|
|
||||||
|
`admin`, `user`
|
||||||
|
|
||||||
|
### Team Member Roles
|
||||||
|
|
||||||
|
`admin`, `member`
|
||||||
|
|
||||||
|
### Provider Types
|
||||||
|
|
||||||
|
`openai`, `anthropic`, `openrouter`, `venice` (extensible via registry)
|
||||||
|
|
||||||
|
### Model Types
|
||||||
|
|
||||||
|
`chat`, `embedding`, `image`
|
||||||
|
|
||||||
|
### Memory Scopes
|
||||||
|
|
||||||
|
`user`, `persona`
|
||||||
|
|
||||||
|
### Memory Statuses
|
||||||
|
|
||||||
|
`pending`, `approved`, `rejected`
|
||||||
|
|
||||||
|
### Workspace Owner Types
|
||||||
|
|
||||||
|
`user`, `project`
|
||||||
|
|
||||||
|
### Workspace Statuses
|
||||||
|
|
||||||
|
`active`, `archived`
|
||||||
|
|
||||||
|
### Index Statuses
|
||||||
|
|
||||||
|
`pending`, `indexing`, `ready`, `error`
|
||||||
|
|
||||||
|
### KB Document Statuses
|
||||||
|
|
||||||
|
`pending`, `chunking`, `embedding`, `ready`, `error`
|
||||||
|
|
||||||
|
### Provider Health Statuses
|
||||||
|
|
||||||
|
`healthy`, `degraded`, `down`
|
||||||
|
|
||||||
|
### Routing Policy Types
|
||||||
|
|
||||||
|
`provider_prefer`, `team_route`, `cost_limit`, `model_alias`
|
||||||
|
|
||||||
|
### Extension Tiers
|
||||||
|
|
||||||
|
`browser` (implemented), `starlark` (future), `sidecar` (future)
|
||||||
|
|
||||||
|
### Grant Types
|
||||||
|
|
||||||
|
`team_only`, `global`, `groups`
|
||||||
|
|
||||||
|
### Resource Grant Scopes
|
||||||
|
|
||||||
|
`persona`, `kb`
|
||||||
|
|
||||||
|
### Notification Types
|
||||||
|
|
||||||
|
`role.fallback`, `memory.extracted`, `system.announcement`, plus
|
||||||
|
extensible via notification service.
|
||||||
|
|
||||||
|
### Git Auth Types
|
||||||
|
|
||||||
|
`https_pat`, `https_basic`, `ssh_key`
|
||||||
|
|
||||||
|
### Policies
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `allow_registration` | `"true"` | Allow new user self-registration |
|
||||||
|
| `allow_user_byok` | `"true"` | Allow users to add personal API keys |
|
||||||
|
| `allow_user_personas` | `"true"` | Allow users to create personal Personas |
|
||||||
|
| `allow_team_providers` | `"true"` | Allow team admins to configure team providers |
|
||||||
|
| `kb_direct_access` | `"true"` | Show KB picker in channel (false = strict enterprise mode) |
|
||||||
|
| `require_email` | `"false"` | Require email on registration |
|
||||||
|
| `default_system_prompt` | `""` | Injected into all conversations (admin override) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page Routes (Non-API)
|
||||||
|
|
||||||
|
Server-rendered Go template surfaces. Not REST endpoints — these
|
||||||
|
return HTML pages.
|
||||||
|
|
||||||
|
| Route | Surface | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `/login` | Login | Standalone login/register page |
|
||||||
|
| `/` | Chat | Main chat interface |
|
||||||
|
| `/chat/:chatID` | Chat | Chat with specific channel loaded |
|
||||||
|
| `/editor` | Editor | Workspace file editor |
|
||||||
|
| `/editor/:wsId` | Editor | Editor with specific workspace |
|
||||||
|
| `/notes` | Notes | Notes interface |
|
||||||
|
| `/notes/:noteId` | Notes | Notes with specific note loaded |
|
||||||
|
| `/admin` | Admin | Platform administration |
|
||||||
|
| `/admin/:section` | Admin | Admin with specific section |
|
||||||
|
| `/settings` | Settings | User settings |
|
||||||
|
| `/settings/:section` | Settings | Settings with specific section |
|
||||||
|
|
||||||
|
All page routes (except `/login`) require authentication via
|
||||||
|
`AuthOrRedirect` middleware (reads `sb_token` cookie). Admin routes
|
||||||
|
additionally require `RequireAdminPage()`.
|
||||||
|
|
||||||
|
**Dynamic surface routing** for admin-installed extension surfaces is
|
||||||
|
not yet implemented. The current five surfaces are hardcoded in Go
|
||||||
|
route registration. Future: extension manifests declare surface routes,
|
||||||
|
the page engine dynamically registers them with appropriate data
|
||||||
|
loaders. See EXTENSIONS.md for the design direction.
|
||||||
472
docs/ICD/workflows.md
Normal file
472
docs/ICD/workflows.md
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
# Workflows
|
||||||
|
|
||||||
|
Team-owned, stage-based process execution. The channel is the runtime —
|
||||||
|
personas drive each stage, and the existing tool/notes infrastructure
|
||||||
|
handles structured data collection.
|
||||||
|
|
||||||
|
## Definitions
|
||||||
|
|
||||||
|
### List Workflows
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /workflows?team_id=...
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "data": [...] }`. If `team_id` is provided, scoped to that team.
|
||||||
|
Otherwise returns global (team_id IS NULL) workflows.
|
||||||
|
|
||||||
|
**Auth:** Authenticated.
|
||||||
|
|
||||||
|
### Create Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /workflows
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Customer Intake",
|
||||||
|
"slug": "customer-intake",
|
||||||
|
"description": "Collect customer info and route to support",
|
||||||
|
"entry_mode": "public_link",
|
||||||
|
"team_id": "uuid|null",
|
||||||
|
"branding": { "accent_color": "#2563eb", "tagline": "Welcome" },
|
||||||
|
"retention": { "mode": "archive", "delete_after_days": 90 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`slug` auto-generated from `name` if omitted. Must be 2-64 chars,
|
||||||
|
lowercase alphanumeric and hyphens. Unique within scope (team or global).
|
||||||
|
|
||||||
|
`entry_mode`: `public_link` (visitors can start via URL) or `team_only`
|
||||||
|
(only team members can start instances).
|
||||||
|
|
||||||
|
**Auth:** `workflow.create` permission required.
|
||||||
|
|
||||||
|
**Response:** 201 with workflow object.
|
||||||
|
|
||||||
|
### Get Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /workflows/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns workflow with its stages included in the `stages` array.
|
||||||
|
|
||||||
|
**Auth:** Authenticated.
|
||||||
|
|
||||||
|
### Update Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /workflows/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Partial update. Accepted fields: `name`, `description`, `branding`,
|
||||||
|
`entry_mode`, `is_active`, `on_complete`, `retention`, `webhook_url`.
|
||||||
|
|
||||||
|
Any edit increments the `version` number.
|
||||||
|
|
||||||
|
**Auth:** `workflow.create` permission required.
|
||||||
|
|
||||||
|
### Delete Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /workflows/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Cascades: deletes all stages, versions, and active instances.
|
||||||
|
|
||||||
|
**Auth:** `workflow.create` permission required.
|
||||||
|
|
||||||
|
### Workflow Object
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"team_id": "uuid|null",
|
||||||
|
"name": "Customer Intake",
|
||||||
|
"slug": "customer-intake",
|
||||||
|
"description": "...",
|
||||||
|
"branding": { "accent_color": "#2563eb", "logo_url": "...", "tagline": "..." },
|
||||||
|
"entry_mode": "public_link|team_only",
|
||||||
|
"is_active": true,
|
||||||
|
"version": 2,
|
||||||
|
"on_complete": { "action": "start_workflow", "target_slug": "follow-up", "data_map": {"name": "customer_name"} },
|
||||||
|
"retention": { "mode": "archive|delete", "delete_after_days": 90 },
|
||||||
|
"webhook_url": "https://...",
|
||||||
|
"webhook_secret": "...",
|
||||||
|
"created_by": "uuid",
|
||||||
|
"created_at": "...",
|
||||||
|
"updated_at": "...",
|
||||||
|
"stages": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stages
|
||||||
|
|
||||||
|
Ordered steps within a workflow. Each stage has a driving persona and
|
||||||
|
optional human assignment team.
|
||||||
|
|
||||||
|
### List Stages
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /workflows/:id/stages
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "data": [...] }` ordered by `ordinal`.
|
||||||
|
|
||||||
|
**Auth:** Authenticated.
|
||||||
|
|
||||||
|
### Create Stage
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /workflows/:id/stages
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Collect Info",
|
||||||
|
"ordinal": 0,
|
||||||
|
"persona_id": "uuid|null",
|
||||||
|
"assignment_team_id": "uuid|null",
|
||||||
|
"form_template": { "fields": ["name", "email", "issue"] },
|
||||||
|
"history_mode": "full|summary|fresh",
|
||||||
|
"auto_transition": false,
|
||||||
|
"transition_rules": { "auto_assign": "round_robin" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`history_mode`: what chat history the next stage sees. `full` = complete,
|
||||||
|
`summary` = utility-role summary, `fresh` = clean slate.
|
||||||
|
|
||||||
|
`form_template`: injected into the completion system prompt as guidance
|
||||||
|
for what the persona should collect. Not rendered as UI — the LLM is
|
||||||
|
told what to ask for.
|
||||||
|
|
||||||
|
`transition_rules.auto_assign`: `round_robin` assigns to team members
|
||||||
|
in rotation. Null = unassigned (manual claim).
|
||||||
|
|
||||||
|
**Auth:** `workflow.create` permission required.
|
||||||
|
|
||||||
|
### Update Stage
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /workflows/:id/stages/:sid
|
||||||
|
```
|
||||||
|
|
||||||
|
Full replacement of stage fields.
|
||||||
|
|
||||||
|
**Auth:** `workflow.create` permission required.
|
||||||
|
|
||||||
|
### Delete Stage
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /workflows/:id/stages/:sid
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auth:** `workflow.create` permission required.
|
||||||
|
|
||||||
|
### Reorder Stages
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /workflows/:id/stages/reorder
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ordered_ids": ["stage-uuid-2", "stage-uuid-1", "stage-uuid-3"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Sets ordinals based on array position.
|
||||||
|
|
||||||
|
**Auth:** `workflow.create` permission required.
|
||||||
|
|
||||||
|
### Stage Object
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"workflow_id": "uuid",
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "Collect Info",
|
||||||
|
"persona_id": "uuid|null",
|
||||||
|
"assignment_team_id": "uuid|null",
|
||||||
|
"form_template": {},
|
||||||
|
"history_mode": "full",
|
||||||
|
"auto_transition": false,
|
||||||
|
"transition_rules": {},
|
||||||
|
"created_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Versions (Immutable Snapshots)
|
||||||
|
|
||||||
|
### Publish
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /workflows/:id/publish
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates an immutable snapshot of the current workflow definition, stages,
|
||||||
|
and persona tool grants. Running instances are pinned to their version.
|
||||||
|
|
||||||
|
Returns 201 with the version object. Returns 409 if the current version
|
||||||
|
number has already been published (edit the workflow to increment first).
|
||||||
|
|
||||||
|
**Auth:** `workflow.create` permission required.
|
||||||
|
|
||||||
|
### Get Version
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /workflows/:id/versions/:version
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the version snapshot.
|
||||||
|
|
||||||
|
**Auth:** Authenticated.
|
||||||
|
|
||||||
|
### Version Object
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"workflow_id": "uuid",
|
||||||
|
"version_number": 2,
|
||||||
|
"snapshot": { "workflow": {...}, "stages": [...] },
|
||||||
|
"created_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Instances
|
||||||
|
|
||||||
|
A workflow **instance** is a channel with `type=workflow`. The channel's
|
||||||
|
`workflow_id`, `workflow_version`, `current_stage`, `stage_data`, and
|
||||||
|
`workflow_status` columns track instance state.
|
||||||
|
|
||||||
|
### Start Instance
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /workflows/:id/start
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates a new workflow channel, sets `current_stage=0`, pins the latest
|
||||||
|
published version. Returns the channel object.
|
||||||
|
|
||||||
|
**Auth:** Authenticated (for team members). Visitors use the entry API.
|
||||||
|
|
||||||
|
### Get Instance Status
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /channels/:id/workflow/status
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"workflow_id": "uuid",
|
||||||
|
"workflow_version": 2,
|
||||||
|
"current_stage": 1,
|
||||||
|
"stage_data": { "name": "Jane", "email": "jane@co.com" },
|
||||||
|
"status": "active|completed|stale",
|
||||||
|
"last_activity_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auth:** Authenticated.
|
||||||
|
|
||||||
|
### Advance Stage
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /channels/:id/workflow/advance
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "data": { "name": "Jane", "email": "jane@co.com" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Merges `data` into the channel's accumulated stage data and moves to
|
||||||
|
the next stage. If the current stage is the last, marks the workflow
|
||||||
|
as `completed` and sets `ai_mode=off`.
|
||||||
|
|
||||||
|
On completion, triggers `on_complete` chaining and webhook delivery
|
||||||
|
if configured.
|
||||||
|
|
||||||
|
**Auth:** Authenticated.
|
||||||
|
|
||||||
|
### Reject (Go Back)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /channels/:id/workflow/reject
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "reason": "Missing required field: email" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Moves back one stage. Cannot go below stage 0. `reason` is required —
|
||||||
|
persisted as a system message in the channel history.
|
||||||
|
|
||||||
|
**Auth:** Authenticated.
|
||||||
|
|
||||||
|
### Workflow Advance Tool
|
||||||
|
|
||||||
|
The `workflow_advance` tool is available to personas during workflow
|
||||||
|
completions. When the LLM determines enough data has been collected,
|
||||||
|
it calls this tool to trigger the stage transition programmatically.
|
||||||
|
The tool has a `RequireWorkflow` predicate — it only appears in
|
||||||
|
workflow channels.
|
||||||
|
|
||||||
|
## Assignments
|
||||||
|
|
||||||
|
Human review queue for workflow stages that have an `assignment_team_id`.
|
||||||
|
|
||||||
|
### List My Assignments
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /workflow-assignments/mine
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns assignments where `assigned_to` is the current user or
|
||||||
|
`status=unassigned` for the user's teams.
|
||||||
|
|
||||||
|
**Auth:** Authenticated.
|
||||||
|
|
||||||
|
### Claim Assignment
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /workflow-assignments/:id/claim
|
||||||
|
```
|
||||||
|
|
||||||
|
Sets `assigned_to` to the current user, status to `claimed`.
|
||||||
|
Returns 409 if the assignment is already claimed or not found.
|
||||||
|
|
||||||
|
**Auth:** Authenticated.
|
||||||
|
|
||||||
|
### Complete Assignment
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /workflow-assignments/:id/complete
|
||||||
|
```
|
||||||
|
|
||||||
|
Marks assignment as `completed`. Does NOT auto-advance the stage —
|
||||||
|
the human reviews and manually advances or the persona tool does it.
|
||||||
|
Returns 409 if the assignment is not in `claimed` state.
|
||||||
|
|
||||||
|
**Auth:** Authenticated.
|
||||||
|
|
||||||
|
### List Team Assignments
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /teams/:teamId/assignments?status=unassigned
|
||||||
|
```
|
||||||
|
|
||||||
|
Assignments for the team, filtered by `status` (default: `unassigned`).
|
||||||
|
Valid values: `unassigned`, `claimed`, `completed`.
|
||||||
|
|
||||||
|
**Auth:** Authenticated (team member).
|
||||||
|
|
||||||
|
### Assignment Object
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"channel_id": "uuid",
|
||||||
|
"stage": 1,
|
||||||
|
"team_id": "uuid",
|
||||||
|
"assigned_to": "uuid|null",
|
||||||
|
"status": "unassigned|claimed|completed",
|
||||||
|
"created_at": "...",
|
||||||
|
"claimed_at": "...|null",
|
||||||
|
"completed_at": "...|null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Visitor Entry
|
||||||
|
|
||||||
|
Public-facing workflow entry for unauthenticated visitors.
|
||||||
|
|
||||||
|
### Landing Page
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /w/:id
|
||||||
|
GET /w/:id/:slug
|
||||||
|
```
|
||||||
|
|
||||||
|
Server-rendered branded page. Shows workflow name, tagline, branding
|
||||||
|
colors, and a "Start" button. If the visitor has an existing session
|
||||||
|
cookie for this workflow, resumes the conversation.
|
||||||
|
|
||||||
|
Not an API endpoint — returns HTML.
|
||||||
|
|
||||||
|
### Start Visitor Session
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/workflow-entry/:scope/:slug
|
||||||
|
```
|
||||||
|
|
||||||
|
No request body. Display name is auto-generated (`Visitor`,
|
||||||
|
`Visitor 2`, etc.).
|
||||||
|
|
||||||
|
`:scope` is a team ID or `global`. Creates a session participant,
|
||||||
|
creates a workflow channel, sets `allow_anonymous=true`. Returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channel_id": "uuid",
|
||||||
|
"session_id": "uuid",
|
||||||
|
"redirect_to": "/w/channel-uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The session token is set as a cookie (`sb_session`) for subsequent
|
||||||
|
requests. `redirect_to` is the workflow chat surface URL.
|
||||||
|
|
||||||
|
### Visitor Message/Completion
|
||||||
|
|
||||||
|
Visitors interact through the workflow channel using session-scoped routes:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/w/:id/messages
|
||||||
|
GET /api/v1/w/:id/messages
|
||||||
|
POST /api/v1/w/:id/completions
|
||||||
|
```
|
||||||
|
|
||||||
|
These use `AuthOrSession` middleware — the session cookie identifies
|
||||||
|
the visitor without requiring a JWT.
|
||||||
|
|
||||||
|
## Staleness Sweep
|
||||||
|
|
||||||
|
Background goroutine. Checks active workflow instances for staleness
|
||||||
|
(no activity within `WORKFLOW_STALE_HOURS`, default 72). Stale instances
|
||||||
|
are marked `workflow_status=stale`. Retention policy then applies:
|
||||||
|
`archive` keeps the channel, `delete` removes it after `delete_after_days`.
|
||||||
|
|
||||||
|
## On-Complete Chaining
|
||||||
|
|
||||||
|
When a workflow completes and has `on_complete` configured:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"on_complete": {
|
||||||
|
"action": "start_workflow",
|
||||||
|
"target_slug": "follow-up-survey",
|
||||||
|
"data_map": { "name": "customer_name", "case_id": "case_id" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`action` must be `start_workflow`. `target_slug` identifies the workflow
|
||||||
|
to chain into (looked up in the same team scope). `data_map` is a flat
|
||||||
|
key remapping from source stage data keys to target initial data keys.
|
||||||
|
If `data_map` is empty/omitted, all stage data is passed through as-is.
|
||||||
|
|
||||||
|
The system starts a new instance of the target workflow, passing mapped
|
||||||
|
stage data as initial context. Webhook delivery (if configured) fires
|
||||||
|
before chaining.
|
||||||
|
|
||||||
|
## WebSocket Events
|
||||||
|
|
||||||
|
| Event | When |
|
||||||
|
|-------|------|
|
||||||
|
| `workflow.advanced` | Stage transition (includes new stage info) |
|
||||||
|
| `workflow.completed` | Workflow finished |
|
||||||
|
| `workflow.assigned` | New assignment created |
|
||||||
|
| `workflow.claimed` | Assignment claimed by user |
|
||||||
137
docs/ICD/workspaces.md
Normal file
137
docs/ICD/workspaces.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Workspaces & Git
|
||||||
|
|
||||||
|
The **Workspace** is a virtual filesystem for the editor surface.
|
||||||
|
Each workspace has a root directory, file operations, optional Git
|
||||||
|
integration, and full-text indexing for code search.
|
||||||
|
|
||||||
|
### Workspace CRUD
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /workspaces → { "data": [...] }
|
||||||
|
POST /workspaces ← { "name", "owner_type", "owner_id" }
|
||||||
|
GET /workspaces/:id → workspace object
|
||||||
|
PATCH /workspaces/:id ← partial update
|
||||||
|
DELETE /workspaces/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Workspace object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "my-project",
|
||||||
|
"owner_type": "user|project",
|
||||||
|
"owner_id": "uuid",
|
||||||
|
"status": "active|archived",
|
||||||
|
"storage_bytes": 1048576,
|
||||||
|
"file_count": 42,
|
||||||
|
"created_at": "...",
|
||||||
|
"updated_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Operations
|
||||||
|
|
||||||
|
All paths are relative to the workspace root.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /workspaces/:id/files?path=/ → { "files": [entries] }
|
||||||
|
GET /workspaces/:id/files/read?path=/a.md → { "content": "...", "size": 123 }
|
||||||
|
PUT /workspaces/:id/files/write ← { "path": "/a.md", "content": "..." }
|
||||||
|
DELETE /workspaces/:id/files/delete ← { "path": "/a.md" }
|
||||||
|
POST /workspaces/:id/files/mkdir ← { "path": "/src" }
|
||||||
|
```
|
||||||
|
|
||||||
|
File entry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "main.go",
|
||||||
|
"path": "/src/main.go",
|
||||||
|
"is_dir": false,
|
||||||
|
"size": 4096,
|
||||||
|
"modified": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Archive
|
||||||
|
|
||||||
|
**Download** (tar.gz of entire workspace):
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /workspaces/:id/archive/download
|
||||||
|
```
|
||||||
|
|
||||||
|
**Upload** (restore from tar.gz):
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /workspaces/:id/archive/upload
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reconcile, Stats, Index
|
||||||
|
|
||||||
|
**Reconcile** (sync filesystem state with DB metadata):
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /workspaces/:id/reconcile
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stats:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /workspaces/:id/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ "file_count", "storage_bytes", "indexed_files", ... }`.
|
||||||
|
|
||||||
|
**Index status:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /workspaces/:id/index-status
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns indexing progress for full-text search.
|
||||||
|
|
||||||
|
### Git Integration
|
||||||
|
|
||||||
|
All Git operations are workspace-scoped.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /workspaces/:id/git/clone ← { "url", "branch", "credential_id" }
|
||||||
|
POST /workspaces/:id/git/pull
|
||||||
|
POST /workspaces/:id/git/push
|
||||||
|
GET /workspaces/:id/git/status → { "files": [{ "path", "status" }] }
|
||||||
|
GET /workspaces/:id/git/diff → { "diff": "..." }
|
||||||
|
POST /workspaces/:id/git/commit ← { "message", "files": [...] }
|
||||||
|
GET /workspaces/:id/git/log → { "commits": [...] }
|
||||||
|
GET /workspaces/:id/git/branches → { "branches": [...], "current": "main" }
|
||||||
|
POST /workspaces/:id/git/checkout ← { "branch": "feature-x" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Git Credentials
|
||||||
|
|
||||||
|
User-owned, encrypted credential storage for Git operations.
|
||||||
|
Encrypted with the user's UEK (same as BYOK keys — admins cannot
|
||||||
|
recover).
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /git-credentials ← { "name", "auth_type", "token"|"username"+"password"|"ssh_key" }
|
||||||
|
GET /git-credentials → { "data": [GitCredentialSummary] }
|
||||||
|
DELETE /git-credentials/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
`auth_type`: `https_pat`, `https_basic`, or `ssh_key`.
|
||||||
|
|
||||||
|
Summary response (never exposes encrypted data):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "GitHub PAT",
|
||||||
|
"auth_type": "https_pat",
|
||||||
|
"created_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
@@ -42,6 +42,9 @@ CREATE TABLE IF NOT EXISTS groups (
|
|||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_scope
|
||||||
|
ON groups(name, COALESCE(team_id, ''));
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS group_members (
|
CREATE TABLE IF NOT EXISTS group_members (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
-- ==========================================
|
|
||||||
-- Chat Switchboard — 006 Multi-User: DMs + Channels (SQLite)
|
|
||||||
-- ==========================================
|
|
||||||
-- SQLite does not support ALTER CONSTRAINT or DROP CONSTRAINT.
|
|
||||||
-- The type check is enforced by application logic for SQLite.
|
|
||||||
-- ==========================================
|
|
||||||
|
|
||||||
-- ── ai_mode ──────────────────────────────────────────────────────────
|
|
||||||
-- SQLite ignores CHECK constraints on existing rows; we add the column
|
|
||||||
-- and rely on app-layer validation for the enum values.
|
|
||||||
ALTER TABLE channels ADD COLUMN ai_mode TEXT NOT NULL DEFAULT 'auto';
|
|
||||||
|
|
||||||
-- ── topic ─────────────────────────────────────────────────────────────
|
|
||||||
ALTER TABLE channels ADD COLUMN topic TEXT;
|
|
||||||
|
|
||||||
-- ── Presence ──────────────────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS user_presence (
|
|
||||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
last_seen DATETIME NOT NULL DEFAULT (datetime('now')),
|
|
||||||
status TEXT NOT NULL DEFAULT 'online'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_presence_last_seen ON user_presence(last_seen DESC);
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- Chat Switchboard — 017 Unread Counts (SQLite)
|
|
||||||
|
|
||||||
ALTER TABLE channel_participants ADD COLUMN last_read_message_id TEXT;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
-- Chat Switchboard — 018 Auth Abstraction (SQLite) (v0.24.0)
|
|
||||||
ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'builtin';
|
|
||||||
ALTER TABLE users ADD COLUMN external_id TEXT;
|
|
||||||
ALTER TABLE users ADD COLUMN handle TEXT;
|
|
||||||
UPDATE users SET handle = LOWER(REPLACE(REPLACE(username, ' ', '-'), '_', '-'))
|
|
||||||
WHERE handle IS NULL;
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_handle ON users(handle COLLATE NOCASE);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external_id ON users(auth_source, external_id);
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
-- Chat Switchboard — 019 Auth Providers (SQLite) (v0.24.1)
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS oidc_auth_state (
|
|
||||||
state TEXT PRIMARY KEY,
|
|
||||||
nonce TEXT NOT NULL,
|
|
||||||
redirect_to TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_oidc_state_created
|
|
||||||
ON oidc_auth_state(created_at);
|
|
||||||
|
|
||||||
ALTER TABLE groups ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
-- Chat Switchboard — 020 Permissions (SQLite) (v0.24.2)
|
|
||||||
|
|
||||||
-- SQLite cannot drop NOT NULL inline; recreating the constraint isn't needed
|
|
||||||
-- since SQLite doesn't enforce CHECK constraints strictly and the column
|
|
||||||
-- already accepts NULL in practice. The store layer guards source='system'.
|
|
||||||
|
|
||||||
ALTER TABLE groups ADD COLUMN permissions TEXT NOT NULL DEFAULT '[]';
|
|
||||||
ALTER TABLE groups ADD COLUMN token_budget_daily INTEGER;
|
|
||||||
ALTER TABLE groups ADD COLUMN token_budget_monthly INTEGER;
|
|
||||||
ALTER TABLE groups ADD COLUMN allowed_models TEXT;
|
|
||||||
|
|
||||||
-- Seed the Everyone group with a stable well-known ID.
|
|
||||||
INSERT OR IGNORE INTO groups
|
|
||||||
(id, name, description, scope, created_by, source, permissions, created_at, updated_at)
|
|
||||||
VALUES (
|
|
||||||
'00000000-0000-0000-0000-000000000001',
|
|
||||||
'Everyone',
|
|
||||||
'Implicit group — all authenticated users receive these permissions.',
|
|
||||||
'global',
|
|
||||||
NULL,
|
|
||||||
'system',
|
|
||||||
'["model.use","kb.read","channel.create"]',
|
|
||||||
datetime('now'),
|
|
||||||
datetime('now')
|
|
||||||
);
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
-- 021_sessions.sql (SQLite)
|
|
||||||
-- Anonymous / session participants for workflow channels (v0.24.3)
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS session_participants (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
session_token TEXT NOT NULL UNIQUE,
|
|
||||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
||||||
display_name TEXT NOT NULL DEFAULT 'Visitor',
|
|
||||||
fingerprint TEXT,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_session_participants_channel
|
|
||||||
ON session_participants(channel_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_session_participants_token
|
|
||||||
ON session_participants(session_token);
|
|
||||||
|
|
||||||
-- Allow channels to opt in to anonymous session access.
|
|
||||||
ALTER TABLE channels ADD COLUMN allow_anonymous INTEGER NOT NULL DEFAULT 0;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
-- 023_workflows.sql (SQLite)
|
|
||||||
-- Workflow definitions, stages, and version snapshots (v0.26.1)
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS workflows (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
slug TEXT NOT NULL,
|
|
||||||
description TEXT NOT NULL DEFAULT '',
|
|
||||||
branding TEXT NOT NULL DEFAULT '{}',
|
|
||||||
entry_mode TEXT NOT NULL DEFAULT 'public_link'
|
|
||||||
CHECK (entry_mode IN ('public_link', 'team_only')),
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 0,
|
|
||||||
version INTEGER NOT NULL DEFAULT 1,
|
|
||||||
on_complete TEXT,
|
|
||||||
retention TEXT NOT NULL DEFAULT '{"mode": "archive"}',
|
|
||||||
created_by TEXT NOT NULL REFERENCES users(id),
|
|
||||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_team_slug
|
|
||||||
ON workflows(team_id, slug) WHERE team_id IS NOT NULL;
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_global_slug
|
|
||||||
ON workflows(slug) WHERE team_id IS NULL;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS workflow_stages (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
workflow_id TEXT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
|
|
||||||
ordinal INTEGER NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
|
|
||||||
assignment_team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
|
|
||||||
form_template TEXT NOT NULL DEFAULT '{}',
|
|
||||||
history_mode TEXT NOT NULL DEFAULT 'full'
|
|
||||||
CHECK (history_mode IN ('full', 'summary', 'fresh')),
|
|
||||||
auto_transition INTEGER NOT NULL DEFAULT 0,
|
|
||||||
transition_rules TEXT NOT NULL DEFAULT '{}',
|
|
||||||
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_workflow_stages_workflow
|
|
||||||
ON workflow_stages(workflow_id, ordinal);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS workflow_versions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
workflow_id TEXT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
|
|
||||||
version_number INTEGER NOT NULL,
|
|
||||||
snapshot TEXT NOT NULL,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
|
||||||
|
|
||||||
UNIQUE (workflow_id, version_number)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_workflow_versions_workflow
|
|
||||||
ON workflow_versions(workflow_id, version_number);
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
-- 024_workflow_instances.sql (SQLite)
|
|
||||||
-- Workflow instance columns on channels (v0.26.2)
|
|
||||||
|
|
||||||
ALTER TABLE channels ADD COLUMN workflow_id TEXT REFERENCES workflows(id) ON DELETE SET NULL;
|
|
||||||
ALTER TABLE channels ADD COLUMN workflow_version INTEGER;
|
|
||||||
ALTER TABLE channels ADD COLUMN current_stage INTEGER DEFAULT 0;
|
|
||||||
ALTER TABLE channels ADD COLUMN stage_data TEXT DEFAULT '{}';
|
|
||||||
ALTER TABLE channels ADD COLUMN workflow_status TEXT DEFAULT 'active'
|
|
||||||
CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled'));
|
|
||||||
ALTER TABLE channels ADD COLUMN last_activity_at DATETIME DEFAULT (datetime('now'));
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_channels_workflow
|
|
||||||
ON channels(workflow_id) WHERE workflow_id IS NOT NULL;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
-- 025_workflow_assignments.sql (SQLite)
|
|
||||||
-- Assignment queue for workflow stages (v0.26.4)
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS workflow_assignments (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
||||||
stage INTEGER NOT NULL,
|
|
||||||
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
|
||||||
assigned_to TEXT REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'unassigned'
|
|
||||||
CHECK (status IN ('unassigned', 'claimed', 'completed')),
|
|
||||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
|
||||||
claimed_at DATETIME,
|
|
||||||
completed_at DATETIME
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_workflow_assignments_channel
|
|
||||||
ON workflow_assignments(channel_id, stage);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_workflow_assignments_team_status
|
|
||||||
ON workflow_assignments(team_id, status);
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
-- 026_tasks.sql — Task scheduling for autonomous agents (v0.27.1) — SQLite
|
|
||||||
|
|
||||||
-- Note: SQLite CHECK on channels.type is in the CREATE TABLE (005),
|
|
||||||
-- which cannot be altered. The 'service' type is accepted by convention;
|
|
||||||
-- the Go layer validates.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tasks (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT DEFAULT '',
|
|
||||||
scope TEXT NOT NULL DEFAULT 'personal',
|
|
||||||
|
|
||||||
task_type TEXT NOT NULL DEFAULT 'prompt',
|
|
||||||
persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
|
|
||||||
model_id TEXT,
|
|
||||||
system_prompt TEXT DEFAULT '',
|
|
||||||
user_prompt TEXT DEFAULT '',
|
|
||||||
workflow_id TEXT REFERENCES workflows(id) ON DELETE SET NULL,
|
|
||||||
tool_grants TEXT,
|
|
||||||
|
|
||||||
schedule TEXT NOT NULL,
|
|
||||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
|
||||||
|
|
||||||
max_tokens INTEGER NOT NULL DEFAULT 4096,
|
|
||||||
max_tool_calls INTEGER NOT NULL DEFAULT 10,
|
|
||||||
max_wall_clock INTEGER NOT NULL DEFAULT 300,
|
|
||||||
output_mode TEXT NOT NULL DEFAULT 'channel',
|
|
||||||
output_channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
|
|
||||||
webhook_url TEXT,
|
|
||||||
|
|
||||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
notify_on_complete INTEGER NOT NULL DEFAULT 0,
|
|
||||||
notify_on_failure INTEGER NOT NULL DEFAULT 1,
|
|
||||||
|
|
||||||
last_run_at TEXT,
|
|
||||||
next_run_at TEXT,
|
|
||||||
run_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_next_run ON tasks (next_run_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks (owner_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_team ON tasks (team_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS task_runs (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
||||||
channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'running',
|
|
||||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
completed_at TEXT,
|
|
||||||
tokens_used INTEGER DEFAULT 0,
|
|
||||||
tool_calls INTEGER DEFAULT 0,
|
|
||||||
wall_clock INTEGER DEFAULT 0,
|
|
||||||
error TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_task_runs_task ON task_runs (task_id);
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
-- 027_webhooks.sql — Webhook secrets + workflow webhook support (v0.27.3) — SQLite
|
|
||||||
|
|
||||||
ALTER TABLE tasks ADD COLUMN webhook_secret TEXT;
|
|
||||||
ALTER TABLE workflows ADD COLUMN webhook_url TEXT;
|
|
||||||
ALTER TABLE workflows ADD COLUMN webhook_secret TEXT;
|
|
||||||
@@ -1,21 +1,29 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Workflow Assignment Handler ─────────────
|
// ── Workflow Assignment Handler ─────────────
|
||||||
// Manages the assignment queue for human review stages.
|
// Manages the assignment queue for human review stages.
|
||||||
|
|
||||||
type WorkflowAssignmentHandler struct{}
|
type WorkflowAssignmentHandler struct {
|
||||||
|
hub *events.Hub
|
||||||
|
}
|
||||||
|
|
||||||
func NewWorkflowAssignmentHandler() *WorkflowAssignmentHandler {
|
func NewWorkflowAssignmentHandler(hub ...*events.Hub) *WorkflowAssignmentHandler {
|
||||||
return &WorkflowAssignmentHandler{}
|
h := &WorkflowAssignmentHandler{}
|
||||||
|
if len(hub) > 0 {
|
||||||
|
h.hub = hub[0]
|
||||||
|
}
|
||||||
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
type assignmentRow struct {
|
type assignmentRow struct {
|
||||||
@@ -53,7 +61,7 @@ func (h *WorkflowAssignmentHandler) ListForTeam(c *gin.Context) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a assignmentRow
|
var a assignmentRow
|
||||||
if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID,
|
if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID,
|
||||||
&a.AssignedTo, &a.Status, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt); err != nil {
|
&a.AssignedTo, &a.Status, database.ST(&a.CreatedAt), database.SNT(&a.ClaimedAt), database.SNT(&a.CompletedAt)); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result = append(result, a)
|
result = append(result, a)
|
||||||
@@ -64,17 +72,20 @@ func (h *WorkflowAssignmentHandler) ListForTeam(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListForUser returns assignments claimed by the current user.
|
// ListForUser returns assignments claimed by the current user plus
|
||||||
|
// unassigned assignments for teams the user belongs to.
|
||||||
// GET /api/v1/workflow-assignments/mine
|
// GET /api/v1/workflow-assignments/mine
|
||||||
func (h *WorkflowAssignmentHandler) ListMine(c *gin.Context) {
|
func (h *WorkflowAssignmentHandler) ListMine(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
|
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
|
||||||
SELECT id, channel_id, stage, team_id, assigned_to, status,
|
SELECT DISTINCT wa.id, wa.channel_id, wa.stage, wa.team_id, wa.assigned_to, wa.status,
|
||||||
created_at, claimed_at, completed_at
|
wa.created_at, wa.claimed_at, wa.completed_at
|
||||||
FROM workflow_assignments
|
FROM workflow_assignments wa
|
||||||
WHERE assigned_to = $1 AND status = 'claimed'
|
LEFT JOIN team_members tm ON tm.team_id = wa.team_id AND tm.user_id = $1
|
||||||
ORDER BY claimed_at DESC
|
WHERE (wa.assigned_to = $2 AND wa.status = 'claimed')
|
||||||
`), userID)
|
OR (wa.status = 'unassigned' AND tm.user_id IS NOT NULL)
|
||||||
|
ORDER BY wa.created_at DESC
|
||||||
|
`), userID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"})
|
||||||
return
|
return
|
||||||
@@ -85,7 +96,7 @@ func (h *WorkflowAssignmentHandler) ListMine(c *gin.Context) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a assignmentRow
|
var a assignmentRow
|
||||||
if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID,
|
if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID,
|
||||||
&a.AssignedTo, &a.Status, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt); err != nil {
|
&a.AssignedTo, &a.Status, database.ST(&a.CreatedAt), database.SNT(&a.ClaimedAt), database.SNT(&a.CompletedAt)); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result = append(result, a)
|
result = append(result, a)
|
||||||
@@ -117,6 +128,21 @@ func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) {
|
|||||||
c.JSON(http.StatusConflict, gin.H{"error": "assignment already claimed or not found"})
|
c.JSON(http.StatusConflict, gin.H{"error": "assignment already claimed or not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit workflow.claimed WS event
|
||||||
|
if h.hub != nil {
|
||||||
|
payload, _ := json.Marshal(map[string]any{
|
||||||
|
"assignment_id": assignmentID,
|
||||||
|
"claimed_by": userID,
|
||||||
|
})
|
||||||
|
h.hub.GetBus().Publish(events.Event{
|
||||||
|
Label: "workflow.claimed",
|
||||||
|
Room: assignmentID,
|
||||||
|
Payload: payload,
|
||||||
|
Ts: now.UnixMilli(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"claimed": true, "assignment_id": assignmentID})
|
c.JSON(http.StatusOK, gin.H{"claimed": true, "assignment_id": assignmentID})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,13 +77,17 @@ func (h *WorkflowEntryHandler) StartVisitor(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set workflow columns
|
// Set workflow columns
|
||||||
|
allowAnonVal := interface{}(true)
|
||||||
|
if database.CurrentDialect == database.DialectSQLite {
|
||||||
|
allowAnonVal = 1
|
||||||
|
}
|
||||||
_, err = database.DB.ExecContext(ctx, database.Q(`
|
_, err = database.DB.ExecContext(ctx, database.Q(`
|
||||||
UPDATE channels
|
UPDATE channels
|
||||||
SET workflow_id = $1, workflow_version = $2, current_stage = 0,
|
SET workflow_id = $1, workflow_version = $2, current_stage = 0,
|
||||||
stage_data = '{}', workflow_status = 'active',
|
stage_data = '{}', workflow_status = 'active',
|
||||||
last_activity_at = $3, allow_anonymous = true, ai_mode = 'auto'
|
last_activity_at = $3, allow_anonymous = $4, ai_mode = 'auto'
|
||||||
WHERE id = $4
|
WHERE id = $5
|
||||||
`), wf.ID, ver.VersionNumber, time.Now().UTC(), ch.ID)
|
`), wf.ID, ver.VersionNumber, time.Now().UTC(), allowAnonVal, ch.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to set workflow columns: %v", err)
|
log.Printf("Failed to set workflow columns: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
844
server/handlers/workflow_instance_test.go
Normal file
844
server/handlers/workflow_instance_test.go
Normal file
@@ -0,0 +1,844 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||||
|
authpkg "git.gobha.me/xcaliber/chat-switchboard/auth"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||||
|
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// Extended harness: CRUD + instances + assignments + entry
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
type workflowInstanceHarness struct {
|
||||||
|
*testHarness
|
||||||
|
stores store.Stores
|
||||||
|
adminToken string
|
||||||
|
adminID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness {
|
||||||
|
t.Helper()
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
database.TruncateAll(t)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
JWTSecret: testJWTSecret,
|
||||||
|
BasePath: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
var stores store.Stores
|
||||||
|
if database.IsSQLite() {
|
||||||
|
stores = sqlite.NewStores(database.TestDB)
|
||||||
|
} else {
|
||||||
|
stores = postgres.NewStores(database.TestDB)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
api := r.Group("/api/v1")
|
||||||
|
|
||||||
|
auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider())
|
||||||
|
api.POST("/auth/login", auth.Login)
|
||||||
|
api.POST("/auth/register", auth.Register)
|
||||||
|
|
||||||
|
protected := api.Group("")
|
||||||
|
protected.Use(middleware.Auth(cfg))
|
||||||
|
|
||||||
|
// Workflow CRUD
|
||||||
|
wfH := NewWorkflowHandler(stores)
|
||||||
|
protected.GET("/workflows", wfH.List)
|
||||||
|
protected.POST("/workflows", wfH.Create)
|
||||||
|
protected.GET("/workflows/:id", wfH.Get)
|
||||||
|
protected.PATCH("/workflows/:id", wfH.Update)
|
||||||
|
protected.DELETE("/workflows/:id", wfH.Delete)
|
||||||
|
protected.GET("/workflows/:id/stages", wfH.ListStages)
|
||||||
|
protected.POST("/workflows/:id/stages", wfH.CreateStage)
|
||||||
|
protected.PUT("/workflows/:id/stages/:sid", wfH.UpdateStage)
|
||||||
|
protected.DELETE("/workflows/:id/stages/:sid", wfH.DeleteStage)
|
||||||
|
protected.PATCH("/workflows/:id/stages/reorder", wfH.ReorderStages)
|
||||||
|
protected.POST("/workflows/:id/publish", wfH.Publish)
|
||||||
|
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
|
||||||
|
|
||||||
|
// Workflow instances
|
||||||
|
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil)
|
||||||
|
protected.POST("/workflows/:id/start", wfInstH.Start)
|
||||||
|
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
|
||||||
|
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
|
||||||
|
protected.POST("/channels/:id/workflow/reject", wfInstH.Reject)
|
||||||
|
|
||||||
|
// Assignments
|
||||||
|
wfAssignH := NewWorkflowAssignmentHandler()
|
||||||
|
protected.GET("/workflow-assignments/mine", wfAssignH.ListMine)
|
||||||
|
protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
|
||||||
|
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
|
||||||
|
|
||||||
|
// Teams (admin routes for creating teams + adding members)
|
||||||
|
teamH := NewTeamHandler(nil)
|
||||||
|
admin := api.Group("/admin")
|
||||||
|
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
|
||||||
|
admin.POST("/teams", teamH.CreateTeam)
|
||||||
|
admin.POST("/teams/:id/members", teamH.AddMember)
|
||||||
|
|
||||||
|
// Team-scoped assignment listing (mirrors main.go teamScoped)
|
||||||
|
teamScoped := protected.Group("/teams/:teamId")
|
||||||
|
teamAssignH := NewWorkflowAssignmentHandler()
|
||||||
|
teamScoped.GET("/assignments", teamAssignH.ListForTeam)
|
||||||
|
|
||||||
|
// Visitor entry
|
||||||
|
wfEntry := NewWorkflowEntryHandler(stores)
|
||||||
|
r.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor)
|
||||||
|
|
||||||
|
// Seed admin user
|
||||||
|
adminID := seedInsertReturningID(t,
|
||||||
|
`INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||||
|
"wfinst-admin", "wfinst-admin@test.com", "$2a$10$test", "admin", "wfinst-admin", "builtin",
|
||||||
|
)
|
||||||
|
token := makeToken(adminID, "wfinst-admin@test.com", "admin")
|
||||||
|
|
||||||
|
return &workflowInstanceHarness{
|
||||||
|
testHarness: &testHarness{router: r, t: t},
|
||||||
|
stores: stores,
|
||||||
|
adminToken: token,
|
||||||
|
adminID: adminID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createPublishedWorkflow is a helper that creates a workflow with N stages,
|
||||||
|
// activates it, and publishes it. Returns workflow ID + stage IDs.
|
||||||
|
func (h *workflowInstanceHarness) createPublishedWorkflow(name string, numStages int) (string, []string) {
|
||||||
|
h.t.Helper()
|
||||||
|
|
||||||
|
resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
"entry_mode": "public_link",
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusCreated {
|
||||||
|
h.t.Fatalf("create workflow: %d: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var wf struct{ ID string `json:"id"` }
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &wf)
|
||||||
|
|
||||||
|
stageIDs := make([]string, numStages)
|
||||||
|
for i := 0; i < numStages; i++ {
|
||||||
|
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
||||||
|
"name": fmt.Sprintf("Stage %d", i),
|
||||||
|
"ordinal": i,
|
||||||
|
"history_mode": "full",
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusCreated {
|
||||||
|
h.t.Fatalf("create stage %d: %d: %s", i, resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var st struct{ ID string `json:"id"` }
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &st)
|
||||||
|
stageIDs[i] = st.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate + publish
|
||||||
|
h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{
|
||||||
|
"is_active": true,
|
||||||
|
})
|
||||||
|
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil)
|
||||||
|
if resp.Code != http.StatusCreated {
|
||||||
|
h.t.Fatalf("publish: %d: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return wf.ID, stageIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// #15 — Instance Lifecycle (currently untested)
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// TestWorkflowInstanceLifecycle exercises: start → status → advance → complete.
|
||||||
|
func TestWorkflowInstanceLifecycle(t *testing.T) {
|
||||||
|
h := setupWorkflowInstanceHarness(t)
|
||||||
|
wfID, _ := h.createPublishedWorkflow("Lifecycle Test", 2)
|
||||||
|
|
||||||
|
// ── Start ───────────────────────────────
|
||||||
|
resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil)
|
||||||
|
if resp.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("Start: got %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var startResp struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
WorkflowVersion int `json:"workflow_version"`
|
||||||
|
CurrentStage int `json:"current_stage"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &startResp)
|
||||||
|
if startResp.ChannelID == "" {
|
||||||
|
t.Fatal("Start: channel_id empty")
|
||||||
|
}
|
||||||
|
if startResp.CurrentStage != 0 {
|
||||||
|
t.Errorf("Start: current_stage = %d, want 0", startResp.CurrentStage)
|
||||||
|
}
|
||||||
|
chID := startResp.ChannelID
|
||||||
|
|
||||||
|
// ── Status ──────────────────────────────
|
||||||
|
resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Status: got %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var status struct {
|
||||||
|
WorkflowID *string `json:"workflow_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Stage int `json:"current_stage"`
|
||||||
|
StageData json.RawMessage `json:"stage_data"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &status)
|
||||||
|
if status.Status != "active" {
|
||||||
|
t.Errorf("Status: got %q, want \"active\"", status.Status)
|
||||||
|
}
|
||||||
|
if status.WorkflowID == nil || *status.WorkflowID != wfID {
|
||||||
|
t.Errorf("Status: workflow_id mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Advance (stage 0 → 1) ───────────────
|
||||||
|
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
||||||
|
"data": map[string]string{"name": "Jane", "email": "jane@test.com"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Advance 0→1: got %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var advResp struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
CurrentStage int `json:"current_stage"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &advResp)
|
||||||
|
if advResp.Status != "active" {
|
||||||
|
t.Errorf("Advance 0→1: status = %q, want \"active\"", advResp.Status)
|
||||||
|
}
|
||||||
|
if advResp.CurrentStage != 1 {
|
||||||
|
t.Errorf("Advance 0→1: current_stage = %d, want 1", advResp.CurrentStage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Verify merged data persisted ────────
|
||||||
|
resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil)
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &status)
|
||||||
|
var data map[string]string
|
||||||
|
json.Unmarshal(status.StageData, &data)
|
||||||
|
if data["name"] != "Jane" || data["email"] != "jane@test.com" {
|
||||||
|
t.Errorf("Stage data after advance: got %v", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Advance (stage 1 → complete) ────────
|
||||||
|
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
||||||
|
"data": map[string]string{"resolution": "approved"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Advance 1→complete: got %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &advResp)
|
||||||
|
if advResp.Status != "completed" {
|
||||||
|
t.Errorf("Completion: status = %q, want \"completed\"", advResp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status after completion ──────────────
|
||||||
|
resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil)
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &status)
|
||||||
|
if status.Status != "completed" {
|
||||||
|
t.Errorf("Post-complete status: got %q, want \"completed\"", status.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Advance on completed should fail ────
|
||||||
|
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
||||||
|
"data": map[string]string{"extra": "data"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Advance after complete: got %d, want 400", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkflowInstanceStart_InactiveWorkflow verifies start fails on inactive workflow.
|
||||||
|
func TestWorkflowInstanceStart_InactiveWorkflow(t *testing.T) {
|
||||||
|
h := setupWorkflowInstanceHarness(t)
|
||||||
|
|
||||||
|
// Create workflow with a stage but don't activate
|
||||||
|
resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||||
|
"name": "Inactive WF",
|
||||||
|
})
|
||||||
|
var wf struct{ ID string `json:"id"` }
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &wf)
|
||||||
|
|
||||||
|
h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
||||||
|
"name": "Stage 0", "ordinal": 0, "history_mode": "full",
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/start", h.adminToken, nil)
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Start inactive: got %d, want 400", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkflowInstanceStart_NoPublishedVersion verifies start fails without a published version.
|
||||||
|
func TestWorkflowInstanceStart_NoPublishedVersion(t *testing.T) {
|
||||||
|
h := setupWorkflowInstanceHarness(t)
|
||||||
|
|
||||||
|
resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||||
|
"name": "Unpublished WF",
|
||||||
|
})
|
||||||
|
var wf struct{ ID string `json:"id"` }
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &wf)
|
||||||
|
|
||||||
|
h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
||||||
|
"name": "Stage 0", "ordinal": 0, "history_mode": "full",
|
||||||
|
})
|
||||||
|
// Activate but don't publish
|
||||||
|
h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{
|
||||||
|
"is_active": true,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/start", h.adminToken, nil)
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Start unpublished: got %d, want 400", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// #15 — Reject validation
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// TestWorkflowReject exercises reject validation: reason required, stage-0 guard.
|
||||||
|
func TestWorkflowReject(t *testing.T) {
|
||||||
|
h := setupWorkflowInstanceHarness(t)
|
||||||
|
wfID, _ := h.createPublishedWorkflow("Reject Test", 3)
|
||||||
|
|
||||||
|
// Start
|
||||||
|
resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil)
|
||||||
|
var start struct{ ChannelID string `json:"channel_id"` }
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &start)
|
||||||
|
chID := start.ChannelID
|
||||||
|
|
||||||
|
// ── Reject from stage 0 should fail ─────
|
||||||
|
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/reject", h.adminToken, map[string]interface{}{
|
||||||
|
"reason": "want to go back",
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Reject at stage 0: got %d, want 400", resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reject without reason should fail ───
|
||||||
|
// Advance to stage 1 first
|
||||||
|
h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
||||||
|
"data": map[string]string{"step": "0"},
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/reject", h.adminToken, nil)
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Reject no reason: got %d, want 400", resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reject with empty reason should fail ─
|
||||||
|
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/reject", h.adminToken, map[string]interface{}{
|
||||||
|
"reason": "",
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Reject empty reason: got %d, want 400", resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Valid reject ────────────────────────
|
||||||
|
resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/reject", h.adminToken, map[string]interface{}{
|
||||||
|
"reason": "Missing email field",
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Valid reject: got %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var rejResp struct {
|
||||||
|
CurrentStage int `json:"current_stage"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &rejResp)
|
||||||
|
if rejResp.CurrentStage != 0 {
|
||||||
|
t.Errorf("After reject: current_stage = %d, want 0", rejResp.CurrentStage)
|
||||||
|
}
|
||||||
|
if rejResp.Reason != "Missing email field" {
|
||||||
|
t.Errorf("Reject reason: got %q", rejResp.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// #1 — Advance body field is "data" (not "stage_data")
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// TestWorkflowAdvance_BodyFieldName verifies the advance endpoint reads "data"
|
||||||
|
// from the request body, not "stage_data" (which the old ICD incorrectly documented).
|
||||||
|
func TestWorkflowAdvance_BodyFieldName(t *testing.T) {
|
||||||
|
h := setupWorkflowInstanceHarness(t)
|
||||||
|
wfID, _ := h.createPublishedWorkflow("Field Name Test", 2)
|
||||||
|
|
||||||
|
resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil)
|
||||||
|
var start struct{ ChannelID string `json:"channel_id"` }
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &start)
|
||||||
|
chID := start.ChannelID
|
||||||
|
|
||||||
|
// Send data under the correct "data" key
|
||||||
|
h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
||||||
|
"data": map[string]string{"collected": "yes"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify data was merged
|
||||||
|
resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil)
|
||||||
|
var status struct{ StageData json.RawMessage `json:"stage_data"` }
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &status)
|
||||||
|
var data map[string]string
|
||||||
|
json.Unmarshal(status.StageData, &data)
|
||||||
|
if data["collected"] != "yes" {
|
||||||
|
t.Errorf("Data sent under 'data' key not merged: got %v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// #2 — GetStatus response uses "status" (not "workflow_status")
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// TestWorkflowGetStatus_ResponseShape verifies the exact JSON field names
|
||||||
|
// returned by the status endpoint.
|
||||||
|
func TestWorkflowGetStatus_ResponseShape(t *testing.T) {
|
||||||
|
h := setupWorkflowInstanceHarness(t)
|
||||||
|
wfID, _ := h.createPublishedWorkflow("Status Shape", 1)
|
||||||
|
|
||||||
|
resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil)
|
||||||
|
var start struct{ ChannelID string `json:"channel_id"` }
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &start)
|
||||||
|
|
||||||
|
resp = h.request("GET", "/api/v1/channels/"+start.ChannelID+"/workflow/status", h.adminToken, nil)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GetStatus: %d", resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse as raw map to check exact field names
|
||||||
|
var raw map[string]interface{}
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &raw)
|
||||||
|
|
||||||
|
if _, ok := raw["status"]; !ok {
|
||||||
|
t.Error("Response missing 'status' field — should be 'status', not 'workflow_status'")
|
||||||
|
}
|
||||||
|
if _, ok := raw["workflow_status"]; ok {
|
||||||
|
t.Error("Response has 'workflow_status' — should be 'status' per ICD")
|
||||||
|
}
|
||||||
|
if _, ok := raw["workflow_id"]; !ok {
|
||||||
|
t.Error("Response missing 'workflow_id'")
|
||||||
|
}
|
||||||
|
if _, ok := raw["current_stage"]; !ok {
|
||||||
|
t.Error("Response missing 'current_stage'")
|
||||||
|
}
|
||||||
|
if _, ok := raw["last_activity_at"]; !ok {
|
||||||
|
t.Error("Response missing 'last_activity_at'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// #4 — StartVisitor response shape
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// TestWorkflowVisitorEntry_ResponseShape verifies the visitor start endpoint
|
||||||
|
// returns the correct field names (session_id + redirect_to, not session_token).
|
||||||
|
func TestWorkflowVisitorEntry_ResponseShape(t *testing.T) {
|
||||||
|
h := setupWorkflowInstanceHarness(t)
|
||||||
|
wfID, _ := h.createPublishedWorkflow("Visitor Test", 1)
|
||||||
|
|
||||||
|
// Read slug
|
||||||
|
resp := h.request("GET", "/api/v1/workflows/"+wfID, h.adminToken, nil)
|
||||||
|
var wf struct{ Slug string `json:"slug"` }
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &wf)
|
||||||
|
|
||||||
|
// Visitor start (no auth, no body)
|
||||||
|
resp = h.request("POST", "/api/v1/workflow-entry/global/"+wf.Slug, "", nil)
|
||||||
|
if resp.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("Visitor start: got %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]interface{}
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &raw)
|
||||||
|
|
||||||
|
if _, ok := raw["channel_id"]; !ok {
|
||||||
|
t.Error("Response missing 'channel_id'")
|
||||||
|
}
|
||||||
|
if _, ok := raw["session_id"]; !ok {
|
||||||
|
t.Error("Response missing 'session_id' — should be 'session_id', not 'session_token'")
|
||||||
|
}
|
||||||
|
if _, ok := raw["redirect_to"]; !ok {
|
||||||
|
t.Error("Response missing 'redirect_to'")
|
||||||
|
}
|
||||||
|
// These should NOT be present
|
||||||
|
if _, ok := raw["session_token"]; ok {
|
||||||
|
t.Error("Response has 'session_token' — field was renamed to 'session_id'")
|
||||||
|
}
|
||||||
|
if _, ok := raw["workflow"]; ok {
|
||||||
|
t.Error("Response has 'workflow' object — not in actual response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkflowVisitorEntry_TeamOnlyBlocked verifies team_only workflows reject visitors.
|
||||||
|
func TestWorkflowVisitorEntry_TeamOnlyBlocked(t *testing.T) {
|
||||||
|
h := setupWorkflowInstanceHarness(t)
|
||||||
|
|
||||||
|
resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||||
|
"name": "Team Only WF",
|
||||||
|
"entry_mode": "team_only",
|
||||||
|
})
|
||||||
|
var wf struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &wf)
|
||||||
|
|
||||||
|
h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
||||||
|
"name": "S0", "ordinal": 0, "history_mode": "full",
|
||||||
|
})
|
||||||
|
h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{
|
||||||
|
"is_active": true,
|
||||||
|
})
|
||||||
|
h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil)
|
||||||
|
|
||||||
|
resp = h.request("POST", "/api/v1/workflow-entry/global/"+wf.Slug, "", nil)
|
||||||
|
if resp.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("Visitor on team_only: got %d, want 403", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// #6/#7 — webhook_url/webhook_secret round-trip
|
||||||
|
// EXPECTED TO FAIL until stores are fixed to SELECT/INSERT these columns.
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// TestWorkflowWebhookFields verifies webhook_url and webhook_secret survive
|
||||||
|
// create → get round-trip. This WILL FAIL until the Postgres and SQLite
|
||||||
|
// workflow stores are updated to include these columns in their queries.
|
||||||
|
func TestWorkflowWebhookFields(t *testing.T) {
|
||||||
|
h := setupWorkflowInstanceHarness(t)
|
||||||
|
|
||||||
|
resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||||
|
"name": "Webhook WF",
|
||||||
|
"webhook_url": "https://hooks.example.com/wf",
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("Create with webhook_url: %d: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var wf struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
WebhookURL string `json:"webhook_url"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &wf)
|
||||||
|
|
||||||
|
// BUG #6: webhook_url is not included in the store's INSERT/SELECT,
|
||||||
|
// so it will be empty on the create response.
|
||||||
|
if wf.WebhookURL != "https://hooks.example.com/wf" {
|
||||||
|
t.Errorf("Create response webhook_url: got %q, want %q (BUG #6: store doesn't persist webhook_url)",
|
||||||
|
wf.WebhookURL, "https://hooks.example.com/wf")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also verify via GET
|
||||||
|
resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil)
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &wf)
|
||||||
|
if wf.WebhookURL != "https://hooks.example.com/wf" {
|
||||||
|
t.Errorf("GET webhook_url: got %q, want %q (BUG #6: store doesn't SELECT webhook_url)",
|
||||||
|
wf.WebhookURL, "https://hooks.example.com/wf")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BUG #7: PATCH webhook_url should work
|
||||||
|
resp = h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{
|
||||||
|
"webhook_url": "https://hooks.example.com/updated",
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PATCH webhook_url: %d: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &wf)
|
||||||
|
if wf.WebhookURL != "https://hooks.example.com/updated" {
|
||||||
|
t.Errorf("PATCH webhook_url: got %q, want %q (BUG #7: Update() ignores webhook_url)",
|
||||||
|
wf.WebhookURL, "https://hooks.example.com/updated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// #18 — ListMine should include unassigned-for-my-teams
|
||||||
|
// EXPECTED TO FAIL until ListMine query is fixed.
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// TestWorkflowAssignment_ListMineIncludesTeamUnassigned verifies that
|
||||||
|
// GET /workflow-assignments/mine returns both claimed assignments AND
|
||||||
|
// unassigned assignments for the user's teams.
|
||||||
|
func TestWorkflowAssignment_ListMineIncludesTeamUnassigned(t *testing.T) {
|
||||||
|
h := setupWorkflowInstanceHarness(t)
|
||||||
|
|
||||||
|
// Create a team and add a member
|
||||||
|
memberID := seedInsertReturningID(t,
|
||||||
|
`INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||||
|
"wf-member", "wf-member@test.com", "$2a$10$test", "user", "wf-member", "builtin",
|
||||||
|
)
|
||||||
|
memberToken := makeToken(memberID, "wf-member@test.com", "user")
|
||||||
|
|
||||||
|
resp := h.request("POST", "/api/v1/admin/teams", h.adminToken, map[string]string{
|
||||||
|
"name": "Review Team", "description": "Test team",
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("Create team: %d: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var team map[string]interface{}
|
||||||
|
decode(resp, &team)
|
||||||
|
teamID := team["id"].(string)
|
||||||
|
|
||||||
|
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), h.adminToken,
|
||||||
|
map[string]string{"user_id": memberID, "role": "member"})
|
||||||
|
|
||||||
|
// Create a workflow with an assignment stage
|
||||||
|
wfResp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||||
|
"name": "Assignment WF",
|
||||||
|
"entry_mode": "team_only",
|
||||||
|
})
|
||||||
|
var wf struct{ ID string `json:"id"` }
|
||||||
|
json.Unmarshal(wfResp.Body.Bytes(), &wf)
|
||||||
|
|
||||||
|
h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
||||||
|
"name": "Intake", "ordinal": 0, "history_mode": "full",
|
||||||
|
})
|
||||||
|
h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
||||||
|
"name": "Review",
|
||||||
|
"ordinal": 1,
|
||||||
|
"history_mode": "full",
|
||||||
|
"assignment_team_id": teamID,
|
||||||
|
})
|
||||||
|
h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{
|
||||||
|
"is_active": true,
|
||||||
|
})
|
||||||
|
h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil)
|
||||||
|
|
||||||
|
// Start instance and advance to stage 1 (creates an assignment)
|
||||||
|
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/start", h.adminToken, nil)
|
||||||
|
var start struct{ ChannelID string `json:"channel_id"` }
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &start)
|
||||||
|
|
||||||
|
h.request("POST", "/api/v1/channels/"+start.ChannelID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
||||||
|
"data": map[string]string{"intake": "done"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// BUG #18: ListMine should include the unassigned assignment for
|
||||||
|
// the member's team, but currently only returns claimed assignments.
|
||||||
|
resp = h.request("GET", "/api/v1/workflow-assignments/mine", memberToken, nil)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("ListMine: %d: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var listResp struct {
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
TeamID string `json:"team_id"`
|
||||||
|
To *string `json:"assigned_to"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &listResp)
|
||||||
|
|
||||||
|
if len(listResp.Data) == 0 {
|
||||||
|
t.Error("ListMine returned 0 assignments — BUG #18: should include unassigned assignments for user's teams")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we do get results, verify the shape
|
||||||
|
for _, a := range listResp.Data {
|
||||||
|
if a.TeamID != teamID {
|
||||||
|
t.Errorf("Assignment team_id: got %q, want %q", a.TeamID, teamID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// Assignment Claim + Complete (basic happy path)
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// TestWorkflowAssignment_ClaimAndComplete exercises the claim → complete flow.
|
||||||
|
func TestWorkflowAssignment_ClaimAndComplete(t *testing.T) {
|
||||||
|
h := setupWorkflowInstanceHarness(t)
|
||||||
|
|
||||||
|
// Seed assignment directly (simulates the advance handler creating one)
|
||||||
|
wfID, _ := h.createPublishedWorkflow("Assign WF", 2)
|
||||||
|
|
||||||
|
// Start + advance to create assignment stage
|
||||||
|
resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil)
|
||||||
|
var start struct{ ChannelID string `json:"channel_id"` }
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &start)
|
||||||
|
|
||||||
|
// Manually insert an assignment (since our stages don't have assignment_team_id)
|
||||||
|
teamID := seedInsertReturningID(t,
|
||||||
|
`INSERT INTO teams (name, description, created_by) VALUES ($1, $2, $3) RETURNING id`,
|
||||||
|
"Claim Team", "Test", h.adminID,
|
||||||
|
)
|
||||||
|
assignID := seedInsertReturningID(t,
|
||||||
|
`INSERT INTO workflow_assignments (channel_id, stage, team_id) VALUES ($1, $2, $3) RETURNING id`,
|
||||||
|
start.ChannelID, 0, teamID,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Claim
|
||||||
|
resp = h.request("POST", "/api/v1/workflow-assignments/"+assignID+"/claim", h.adminToken, nil)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Claim: got %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var claimResp struct {
|
||||||
|
Claimed bool `json:"claimed"`
|
||||||
|
AssignmentID string `json:"assignment_id"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &claimResp)
|
||||||
|
if !claimResp.Claimed {
|
||||||
|
t.Error("Claim: claimed should be true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-claim should conflict
|
||||||
|
resp = h.request("POST", "/api/v1/workflow-assignments/"+assignID+"/claim", h.adminToken, nil)
|
||||||
|
if resp.Code != http.StatusConflict {
|
||||||
|
t.Errorf("Double claim: got %d, want 409", resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete
|
||||||
|
resp = h.request("POST", "/api/v1/workflow-assignments/"+assignID+"/complete", h.adminToken, nil)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Complete: got %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-complete should conflict
|
||||||
|
resp = h.request("POST", "/api/v1/workflow-assignments/"+assignID+"/complete", h.adminToken, nil)
|
||||||
|
if resp.Code != http.StatusConflict {
|
||||||
|
t.Errorf("Double complete: got %d, want 409", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// #15 — Status on non-workflow channel returns 404
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
func TestWorkflowGetStatus_NonWorkflowChannel(t *testing.T) {
|
||||||
|
h := setupWorkflowInstanceHarness(t)
|
||||||
|
|
||||||
|
resp := h.request("GET", "/api/v1/channels/00000000-0000-0000-0000-000000000000/workflow/status", h.adminToken, nil)
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("Status on non-existent channel: got %d, want 404", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// #15 — Data merge across multiple advances
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// TestWorkflowAdvance_DataMerge verifies that stage data accumulates
|
||||||
|
// across multiple advance calls (merge, not replace).
|
||||||
|
func TestWorkflowAdvance_DataMerge(t *testing.T) {
|
||||||
|
h := setupWorkflowInstanceHarness(t)
|
||||||
|
wfID, _ := h.createPublishedWorkflow("Merge Test", 3)
|
||||||
|
|
||||||
|
resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil)
|
||||||
|
var start struct{ ChannelID string `json:"channel_id"` }
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &start)
|
||||||
|
chID := start.ChannelID
|
||||||
|
|
||||||
|
// Advance stage 0 → 1 with key "a"
|
||||||
|
h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
||||||
|
"data": map[string]string{"a": "1"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Advance stage 1 → 2 with key "b"
|
||||||
|
h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{
|
||||||
|
"data": map[string]string{"b": "2"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check accumulated data contains both keys
|
||||||
|
resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil)
|
||||||
|
var status struct{ StageData json.RawMessage `json:"stage_data"` }
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &status)
|
||||||
|
var merged map[string]string
|
||||||
|
json.Unmarshal(status.StageData, &merged)
|
||||||
|
|
||||||
|
if merged["a"] != "1" {
|
||||||
|
t.Errorf("Merged data missing key 'a': got %v", merged)
|
||||||
|
}
|
||||||
|
if merged["b"] != "2" {
|
||||||
|
t.Errorf("Merged data missing key 'b': got %v", merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// #17 — PATCH non-existent workflow
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// TestWorkflowPatchNonExistent verifies PATCH to a non-existent ID returns 404.
|
||||||
|
// Documents the confusing code path: Update affects 0 rows (no error),
|
||||||
|
// then GetByID returns sql.ErrNoRows → 404.
|
||||||
|
func TestWorkflowPatchNonExistent(t *testing.T) {
|
||||||
|
h := setupWorkflowInstanceHarness(t)
|
||||||
|
|
||||||
|
resp := h.request("PATCH", "/api/v1/workflows/00000000-0000-0000-0000-000000000000", h.adminToken, map[string]interface{}{
|
||||||
|
"name": "ghost",
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("PATCH non-existent: got %d, want 404", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// #19 — ListForTeam with ?status= filter
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// TestWorkflowAssignment_ListForTeamStatusFilter verifies the ?status=
|
||||||
|
// query param on GET /teams/:teamId/assignments.
|
||||||
|
func TestWorkflowAssignment_ListForTeamStatusFilter(t *testing.T) {
|
||||||
|
h := setupWorkflowInstanceHarness(t)
|
||||||
|
|
||||||
|
// Create team
|
||||||
|
resp := h.request("POST", "/api/v1/admin/teams", h.adminToken, map[string]string{
|
||||||
|
"name": "Filter Team", "description": "Test",
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("Create team: %d: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var team map[string]interface{}
|
||||||
|
decode(resp, &team)
|
||||||
|
teamID := team["id"].(string)
|
||||||
|
|
||||||
|
// Add self as member (needed for access)
|
||||||
|
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), h.adminToken,
|
||||||
|
map[string]string{"user_id": h.adminID, "role": "admin"})
|
||||||
|
|
||||||
|
// Create real channels for FK satisfaction
|
||||||
|
ch1ID := database.SeedTestChannel(t, h.adminID, "filter-ch-1")
|
||||||
|
ch2ID := database.SeedTestChannel(t, h.adminID, "filter-ch-2")
|
||||||
|
|
||||||
|
// Seed two assignments: one unassigned, one claimed
|
||||||
|
seedExec(t,
|
||||||
|
`INSERT INTO workflow_assignments (channel_id, stage, team_id, status) VALUES ($1, $2, $3, $4)`,
|
||||||
|
ch1ID, 0, teamID, "unassigned",
|
||||||
|
)
|
||||||
|
seedExec(t,
|
||||||
|
`INSERT INTO workflow_assignments (channel_id, stage, team_id, status, assigned_to) VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
ch2ID, 1, teamID, "claimed", h.adminID,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default (unassigned)
|
||||||
|
resp = h.request("GET", "/api/v1/teams/"+teamID+"/assignments", h.adminToken, nil)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("ListForTeam default: %d: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var lr struct {
|
||||||
|
Data []struct{ Status string `json:"status"` } `json:"data"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &lr)
|
||||||
|
if len(lr.Data) != 1 {
|
||||||
|
t.Errorf("Default filter: expected 1 unassigned, got %d", len(lr.Data))
|
||||||
|
} else if lr.Data[0].Status != "unassigned" {
|
||||||
|
t.Errorf("Default filter: expected status 'unassigned', got %q", lr.Data[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit ?status=claimed
|
||||||
|
resp = h.request("GET", "/api/v1/teams/"+teamID+"/assignments?status=claimed", h.adminToken, nil)
|
||||||
|
json.Unmarshal(resp.Body.Bytes(), &lr)
|
||||||
|
if len(lr.Data) != 1 {
|
||||||
|
t.Errorf("?status=claimed: expected 1, got %d", len(lr.Data))
|
||||||
|
} else if lr.Data[0].Status != "claimed" {
|
||||||
|
t.Errorf("?status=claimed: expected status 'claimed', got %q", lr.Data[0].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,7 +133,7 @@ type WorkflowChannelStatus struct {
|
|||||||
CurrentStage int `json:"current_stage"`
|
CurrentStage int `json:"current_stage"`
|
||||||
StageData json.RawMessage `json:"stage_data"`
|
StageData json.RawMessage `json:"stage_data"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
LastActivityAt *time.Time `json:"last_activity_at"`
|
LastActivityAt *string `json:"last_activity_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatus returns the workflow state for a channel.
|
// GetStatus returns the workflow state for a channel.
|
||||||
@@ -391,19 +391,19 @@ func (h *WorkflowInstanceHandler) tryRoundRobin(ctx context.Context, stage model
|
|||||||
bestUserID = members[0].UserID // fallback to first member
|
bestUserID = members[0].UserID // fallback to first member
|
||||||
|
|
||||||
rows, err := database.DB.QueryContext(ctx, database.Q(`
|
rows, err := database.DB.QueryContext(ctx, database.Q(`
|
||||||
SELECT m.user_id, MAX(wa.claimed_at) as last_claim
|
SELECT m.user_id, COALESCE(MAX(wa.claimed_at), '1970-01-01T00:00:00Z') as last_claim
|
||||||
FROM team_members m
|
FROM team_members m
|
||||||
LEFT JOIN workflow_assignments wa ON wa.assigned_to = m.user_id AND wa.team_id = $1
|
LEFT JOIN workflow_assignments wa ON wa.assigned_to = m.user_id AND wa.team_id = $1
|
||||||
WHERE m.team_id = $2
|
WHERE m.team_id = $2
|
||||||
GROUP BY m.user_id
|
GROUP BY m.user_id
|
||||||
ORDER BY last_claim ASC NULLS FIRST
|
ORDER BY last_claim ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`), *stage.AssignmentTeamID, *stage.AssignmentTeamID)
|
`), *stage.AssignmentTeamID, *stage.AssignmentTeamID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
if rows.Next() {
|
if rows.Next() {
|
||||||
var uid string
|
var uid string
|
||||||
var lastClaim *time.Time
|
var lastClaim string // COALESCE returns TEXT on both dialects
|
||||||
if err := rows.Scan(&uid, &lastClaim); err == nil {
|
if err := rows.Scan(&uid, &lastClaim); err == nil {
|
||||||
bestUserID = uid
|
bestUserID = uid
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func TestWorkflowCRUD(t *testing.T) {
|
|||||||
|
|
||||||
resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil)
|
resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil)
|
||||||
if resp.Code != http.StatusOK {
|
if resp.Code != http.StatusOK {
|
||||||
t.Fatalf("Get workflow: got %d", resp.Code)
|
t.Fatalf("Get workflow: got %d, body: %s", resp.Code, resp.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── List workflows ──────────────────────
|
// ── List workflows ──────────────────────
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ func (h *WorkflowHandler) List(c *gin.Context) {
|
|||||||
func (h *WorkflowHandler) Get(c *gin.Context) {
|
func (h *WorkflowHandler) Get(c *gin.Context) {
|
||||||
w, err := h.stores.Workflows.GetByID(c.Request.Context(), c.Param("id"))
|
w, err := h.stores.Workflows.GetByID(c.Request.Context(), c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get workflow: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
stages, _ := h.stores.Workflows.ListStages(c.Request.Context(), w.ID)
|
stages, _ := h.stores.Workflows.ListStages(c.Request.Context(), w.ID)
|
||||||
@@ -251,7 +255,11 @@ func (h *WorkflowHandler) Publish(c *gin.Context) {
|
|||||||
wfID := c.Param("id")
|
wfID := c.Param("id")
|
||||||
w, err := h.stores.Workflows.GetByID(c.Request.Context(), wfID)
|
w, err := h.stores.Workflows.GetByID(c.Request.Context(), wfID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get workflow: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -605,7 +605,7 @@ func main() {
|
|||||||
protected.POST("/channels/:id/workflow/reject", wfInstH.Reject)
|
protected.POST("/channels/:id/workflow/reject", wfInstH.Reject)
|
||||||
|
|
||||||
// Workflow assignments (v0.26.4 — team assignment queue)
|
// Workflow assignments (v0.26.4 — team assignment queue)
|
||||||
wfAssignH := handlers.NewWorkflowAssignmentHandler()
|
wfAssignH := handlers.NewWorkflowAssignmentHandler(hub)
|
||||||
protected.GET("/workflow-assignments/mine", wfAssignH.ListMine)
|
protected.GET("/workflow-assignments/mine", wfAssignH.ListMine)
|
||||||
protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
|
protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
|
||||||
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
|
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
|
||||||
@@ -907,7 +907,7 @@ func main() {
|
|||||||
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
|
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
|
||||||
|
|
||||||
// Team workflow assignments (v0.26.4)
|
// Team workflow assignments (v0.26.4)
|
||||||
teamAssignH := handlers.NewWorkflowAssignmentHandler()
|
teamAssignH := handlers.NewWorkflowAssignmentHandler(hub)
|
||||||
teamScoped.GET("/assignments", teamAssignH.ListForTeam)
|
teamScoped.GET("/assignments", teamAssignH.ListForTeam)
|
||||||
|
|
||||||
// Team tasks — admin CRUD (v0.27.5)
|
// Team tasks — admin CRUD (v0.27.5)
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ type WorkflowPatch struct {
|
|||||||
IsActive *bool `json:"is_active,omitempty"`
|
IsActive *bool `json:"is_active,omitempty"`
|
||||||
OnComplete *json.RawMessage `json:"on_complete,omitempty"`
|
OnComplete *json.RawMessage `json:"on_complete,omitempty"`
|
||||||
Retention *json.RawMessage `json:"retention,omitempty"`
|
Retention *json.RawMessage `json:"retention,omitempty"`
|
||||||
WebhookURL *string `json:"webhook_url,omitempty"`
|
WebhookURL *string `json:"webhook_url,omitempty"`
|
||||||
|
WebhookSecret *string `json:"webhook_secret,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Workflow Stage ──────────────────────────
|
// ── Workflow Stage ──────────────────────────
|
||||||
|
|||||||
@@ -19,23 +19,27 @@ func (s *WorkflowStore) Create(ctx context.Context, w *models.Workflow) error {
|
|||||||
branding := jsonOrEmpty(w.Branding)
|
branding := jsonOrEmpty(w.Branding)
|
||||||
retention := jsonOrDefault(w.Retention, `{"mode":"archive"}`)
|
retention := jsonOrDefault(w.Retention, `{"mode":"archive"}`)
|
||||||
return DB.QueryRowContext(ctx, `
|
return DB.QueryRowContext(ctx, `
|
||||||
INSERT INTO workflows (team_id, name, slug, description, branding, entry_mode, is_active, on_complete, retention, created_by)
|
INSERT INTO workflows (team_id, name, slug, description, branding, entry_mode, is_active, on_complete, retention, webhook_url, webhook_secret, created_by)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
RETURNING id, version, created_at, updated_at`,
|
RETURNING id, version, created_at, updated_at`,
|
||||||
w.TeamID, w.Name, w.Slug, w.Description, branding, w.EntryMode,
|
w.TeamID, w.Name, w.Slug, w.Description, branding, w.EntryMode,
|
||||||
w.IsActive, jsonOrNull(w.OnComplete), retention, w.CreatedBy,
|
w.IsActive, jsonOrNull(w.OnComplete), retention,
|
||||||
|
nullIfEmpty(w.WebhookURL), nullIfEmpty(w.WebhookSecret), w.CreatedBy,
|
||||||
).Scan(&w.ID, &w.Version, &w.CreatedAt, &w.UpdatedAt)
|
).Scan(&w.ID, &w.Version, &w.CreatedAt, &w.UpdatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WorkflowStore) GetByID(ctx context.Context, id string) (*models.Workflow, error) {
|
func (s *WorkflowStore) GetByID(ctx context.Context, id string) (*models.Workflow, error) {
|
||||||
w := &models.Workflow{}
|
w := &models.Workflow{}
|
||||||
var branding, retention, onComplete []byte
|
var branding, retention, onComplete []byte
|
||||||
|
var webhookURL, webhookSecret *string
|
||||||
err := DB.QueryRowContext(ctx, `
|
err := DB.QueryRowContext(ctx, `
|
||||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||||
version, on_complete, retention, created_by, created_at, updated_at
|
version, on_complete, retention, webhook_url, webhook_secret,
|
||||||
|
created_by, created_at, updated_at
|
||||||
FROM workflows WHERE id = $1`, id,
|
FROM workflows WHERE id = $1`, id,
|
||||||
).Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
|
).Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
|
||||||
&w.EntryMode, &w.IsActive, &w.Version, &onComplete, &retention,
|
&w.EntryMode, &w.IsActive, &w.Version, &onComplete, &retention,
|
||||||
|
&webhookURL, &webhookSecret,
|
||||||
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt)
|
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -43,6 +47,12 @@ func (s *WorkflowStore) GetByID(ctx context.Context, id string) (*models.Workflo
|
|||||||
w.Branding = branding
|
w.Branding = branding
|
||||||
w.OnComplete = onComplete
|
w.OnComplete = onComplete
|
||||||
w.Retention = retention
|
w.Retention = retention
|
||||||
|
if webhookURL != nil {
|
||||||
|
w.WebhookURL = *webhookURL
|
||||||
|
}
|
||||||
|
if webhookSecret != nil {
|
||||||
|
w.WebhookSecret = *webhookSecret
|
||||||
|
}
|
||||||
return w, nil
|
return w, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,20 +61,24 @@ func (s *WorkflowStore) GetBySlug(ctx context.Context, teamID *string, slug stri
|
|||||||
var args []interface{}
|
var args []interface{}
|
||||||
if teamID != nil {
|
if teamID != nil {
|
||||||
q = `SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
q = `SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||||
version, on_complete, retention, created_by, created_at, updated_at
|
version, on_complete, retention, webhook_url, webhook_secret,
|
||||||
|
created_by, created_at, updated_at
|
||||||
FROM workflows WHERE team_id = $1 AND slug = $2`
|
FROM workflows WHERE team_id = $1 AND slug = $2`
|
||||||
args = []interface{}{*teamID, slug}
|
args = []interface{}{*teamID, slug}
|
||||||
} else {
|
} else {
|
||||||
q = `SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
q = `SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||||
version, on_complete, retention, created_by, created_at, updated_at
|
version, on_complete, retention, webhook_url, webhook_secret,
|
||||||
|
created_by, created_at, updated_at
|
||||||
FROM workflows WHERE team_id IS NULL AND slug = $1`
|
FROM workflows WHERE team_id IS NULL AND slug = $1`
|
||||||
args = []interface{}{slug}
|
args = []interface{}{slug}
|
||||||
}
|
}
|
||||||
w := &models.Workflow{}
|
w := &models.Workflow{}
|
||||||
var branding, retention, onComplete []byte
|
var branding, retention, onComplete []byte
|
||||||
|
var webhookURL, webhookSecret *string
|
||||||
err := DB.QueryRowContext(ctx, q, args...).Scan(
|
err := DB.QueryRowContext(ctx, q, args...).Scan(
|
||||||
&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
|
&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
|
||||||
&w.EntryMode, &w.IsActive, &w.Version, &onComplete, &retention,
|
&w.EntryMode, &w.IsActive, &w.Version, &onComplete, &retention,
|
||||||
|
&webhookURL, &webhookSecret,
|
||||||
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt)
|
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -72,6 +86,12 @@ func (s *WorkflowStore) GetBySlug(ctx context.Context, teamID *string, slug stri
|
|||||||
w.Branding = branding
|
w.Branding = branding
|
||||||
w.OnComplete = onComplete
|
w.OnComplete = onComplete
|
||||||
w.Retention = retention
|
w.Retention = retention
|
||||||
|
if webhookURL != nil {
|
||||||
|
w.WebhookURL = *webhookURL
|
||||||
|
}
|
||||||
|
if webhookSecret != nil {
|
||||||
|
w.WebhookSecret = *webhookSecret
|
||||||
|
}
|
||||||
return w, nil
|
return w, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +128,12 @@ func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.Work
|
|||||||
if patch.Retention != nil {
|
if patch.Retention != nil {
|
||||||
add("retention", string(*patch.Retention))
|
add("retention", string(*patch.Retention))
|
||||||
}
|
}
|
||||||
|
if patch.WebhookURL != nil {
|
||||||
|
add("webhook_url", *patch.WebhookURL)
|
||||||
|
}
|
||||||
|
if patch.WebhookSecret != nil {
|
||||||
|
add("webhook_secret", *patch.WebhookSecret)
|
||||||
|
}
|
||||||
|
|
||||||
if len(sets) == 0 {
|
if len(sets) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -138,7 +164,8 @@ func (s *WorkflowStore) Delete(ctx context.Context, id string) error {
|
|||||||
func (s *WorkflowStore) ListForTeam(ctx context.Context, teamID string) ([]models.Workflow, error) {
|
func (s *WorkflowStore) ListForTeam(ctx context.Context, teamID string) ([]models.Workflow, error) {
|
||||||
return s.queryWorkflows(ctx, `
|
return s.queryWorkflows(ctx, `
|
||||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||||
version, on_complete, retention, created_by, created_at, updated_at
|
version, on_complete, retention, webhook_url, webhook_secret,
|
||||||
|
created_by, created_at, updated_at
|
||||||
FROM workflows WHERE team_id = $1
|
FROM workflows WHERE team_id = $1
|
||||||
ORDER BY name ASC`, teamID)
|
ORDER BY name ASC`, teamID)
|
||||||
}
|
}
|
||||||
@@ -146,7 +173,8 @@ func (s *WorkflowStore) ListForTeam(ctx context.Context, teamID string) ([]model
|
|||||||
func (s *WorkflowStore) ListGlobal(ctx context.Context) ([]models.Workflow, error) {
|
func (s *WorkflowStore) ListGlobal(ctx context.Context) ([]models.Workflow, error) {
|
||||||
return s.queryWorkflows(ctx, `
|
return s.queryWorkflows(ctx, `
|
||||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||||
version, on_complete, retention, created_by, created_at, updated_at
|
version, on_complete, retention, webhook_url, webhook_secret,
|
||||||
|
created_by, created_at, updated_at
|
||||||
FROM workflows WHERE team_id IS NULL
|
FROM workflows WHERE team_id IS NULL
|
||||||
ORDER BY name ASC`)
|
ORDER BY name ASC`)
|
||||||
}
|
}
|
||||||
@@ -161,14 +189,22 @@ func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...in
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var w models.Workflow
|
var w models.Workflow
|
||||||
var branding, retention, onComplete []byte
|
var branding, retention, onComplete []byte
|
||||||
|
var webhookURL, webhookSecret *string
|
||||||
if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description,
|
if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description,
|
||||||
&branding, &w.EntryMode, &w.IsActive, &w.Version, &onComplete,
|
&branding, &w.EntryMode, &w.IsActive, &w.Version, &onComplete,
|
||||||
&retention, &w.CreatedBy, &w.CreatedAt, &w.UpdatedAt); err != nil {
|
&retention, &webhookURL, &webhookSecret,
|
||||||
|
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
w.Branding = branding
|
w.Branding = branding
|
||||||
w.OnComplete = onComplete
|
w.OnComplete = onComplete
|
||||||
w.Retention = retention
|
w.Retention = retention
|
||||||
|
if webhookURL != nil {
|
||||||
|
w.WebhookURL = *webhookURL
|
||||||
|
}
|
||||||
|
if webhookSecret != nil {
|
||||||
|
w.WebhookSecret = *webhookSecret
|
||||||
|
}
|
||||||
result = append(result, w)
|
result = append(result, w)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
@@ -314,3 +350,10 @@ func jsonOrNull(b json.RawMessage) interface{} {
|
|||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nullIfEmpty(s string) interface{} {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func (s *RoutingPolicyStore) GetByID(ctx context.Context, id string) (*models.Ro
|
|||||||
err := DB.QueryRowContext(ctx, `
|
err := DB.QueryRowContext(ctx, `
|
||||||
SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at
|
SELECT id, name, scope, team_id, priority, policy_type, config, is_active, created_at, updated_at
|
||||||
FROM routing_policies WHERE id = ?
|
FROM routing_policies WHERE id = ?
|
||||||
`, id).Scan(&p.ID, &p.Name, &p.Scope, &p.TeamID, &p.Priority, &p.Type, &configJSON, &isActive, &p.CreatedAt, &p.UpdatedAt)
|
`, id).Scan(&p.ID, &p.Name, &p.Scope, &p.TeamID, &p.Priority, &p.Type, &configJSON, &isActive, st(&p.CreatedAt), st(&p.UpdatedAt))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ func (s *RoutingPolicyStore) query(ctx context.Context, q string, args ...interf
|
|||||||
var p models.RoutingPolicy
|
var p models.RoutingPolicy
|
||||||
var configJSON string
|
var configJSON string
|
||||||
var isActive int
|
var isActive int
|
||||||
if err := rows.Scan(&p.ID, &p.Name, &p.Scope, &p.TeamID, &p.Priority, &p.Type, &configJSON, &isActive, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
if err := rows.Scan(&p.ID, &p.Name, &p.Scope, &p.TeamID, &p.Priority, &p.Type, &configJSON, &isActive, st(&p.CreatedAt), st(&p.UpdatedAt)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
p.IsActive = isActive != 0
|
p.IsActive = isActive != 0
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func (s *SessionStore) Create(ctx context.Context, sp *models.SessionParticipant
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return DB.QueryRowContext(ctx, `SELECT created_at FROM session_participants WHERE id = ?`, sp.ID).
|
return DB.QueryRowContext(ctx, `SELECT created_at FROM session_participants WHERE id = ?`, sp.ID).
|
||||||
Scan(&sp.CreatedAt)
|
Scan(st(&sp.CreatedAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SessionStore) GetByToken(ctx context.Context, token string) (*models.SessionParticipant, error) {
|
func (s *SessionStore) GetByToken(ctx context.Context, token string) (*models.SessionParticipant, error) {
|
||||||
@@ -36,7 +36,7 @@ func (s *SessionStore) GetByToken(ctx context.Context, token string) (*models.Se
|
|||||||
err := DB.QueryRowContext(ctx, `
|
err := DB.QueryRowContext(ctx, `
|
||||||
SELECT id, session_token, channel_id, display_name, COALESCE(fingerprint, ''), created_at
|
SELECT id, session_token, channel_id, display_name, COALESCE(fingerprint, ''), created_at
|
||||||
FROM session_participants WHERE session_token = ?`, token,
|
FROM session_participants WHERE session_token = ?`, token,
|
||||||
).Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, &sp.CreatedAt)
|
).Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, st(&sp.CreatedAt))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,7 @@ func (s *SessionStore) GetByID(ctx context.Context, id string) (*models.SessionP
|
|||||||
err := DB.QueryRowContext(ctx, `
|
err := DB.QueryRowContext(ctx, `
|
||||||
SELECT id, session_token, channel_id, display_name, COALESCE(fingerprint, ''), created_at
|
SELECT id, session_token, channel_id, display_name, COALESCE(fingerprint, ''), created_at
|
||||||
FROM session_participants WHERE id = ?`, id,
|
FROM session_participants WHERE id = ?`, id,
|
||||||
).Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, &sp.CreatedAt)
|
).Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, st(&sp.CreatedAt))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,7 @@ func (s *SessionStore) ListForChannel(ctx context.Context, channelID string) ([]
|
|||||||
var result []models.SessionParticipant
|
var result []models.SessionParticipant
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var sp models.SessionParticipant
|
var sp models.SessionParticipant
|
||||||
if err := rows.Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, &sp.CreatedAt); err != nil {
|
if err := rows.Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, st(&sp.CreatedAt)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result = append(result, sp)
|
result = append(result, sp)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func (s *TaskStore) GetByID(ctx context.Context, id string) (*models.Task, error
|
|||||||
&t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode,
|
&t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode,
|
||||||
&t.OutputChannelID, &t.WebhookURL, &t.ProviderConfigID,
|
&t.OutputChannelID, &t.WebhookURL, &t.ProviderConfigID,
|
||||||
&t.NotifyOnComplete, &t.NotifyOnFailure,
|
&t.NotifyOnComplete, &t.NotifyOnFailure,
|
||||||
&t.LastRunAt, &t.NextRunAt, &t.RunCount, &t.CreatedAt, &t.UpdatedAt)
|
stN(&t.LastRunAt), stN(&t.NextRunAt), &t.RunCount, st(&t.CreatedAt), st(&t.UpdatedAt))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,7 @@ func (s *TaskStore) GetActiveRun(ctx context.Context, taskID string) (*models.Ta
|
|||||||
err := DB.QueryRowContext(ctx, `
|
err := DB.QueryRowContext(ctx, `
|
||||||
SELECT id, task_id, channel_id, status, started_at
|
SELECT id, task_id, channel_id, status, started_at
|
||||||
FROM task_runs WHERE task_id = ? AND status = 'running'
|
FROM task_runs WHERE task_id = ? AND status = 'running'
|
||||||
LIMIT 1`, taskID).Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.StartedAt)
|
LIMIT 1`, taskID).Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, st(&r.StartedAt))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -151,8 +151,8 @@ func (s *TaskStore) ListRuns(ctx context.Context, taskID string, limit int) ([]m
|
|||||||
var runs []models.TaskRun
|
var runs []models.TaskRun
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r models.TaskRun
|
var r models.TaskRun
|
||||||
if err := rows.Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.StartedAt,
|
if err := rows.Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, st(&r.StartedAt),
|
||||||
&r.CompletedAt, &r.TokensUsed, &r.ToolCalls, &r.WallClock, &r.Error); err != nil {
|
stN(&r.CompletedAt), &r.TokensUsed, &r.ToolCalls, &r.WallClock, &r.Error); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
runs = append(runs, r)
|
runs = append(runs, r)
|
||||||
@@ -183,7 +183,7 @@ func (s *TaskStore) list(ctx context.Context, where string, args ...interface{})
|
|||||||
&t.WorkflowID, &t.Schedule, &t.Timezone, &t.IsActive,
|
&t.WorkflowID, &t.Schedule, &t.Timezone, &t.IsActive,
|
||||||
&t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode,
|
&t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode,
|
||||||
&t.NotifyOnComplete, &t.NotifyOnFailure,
|
&t.NotifyOnComplete, &t.NotifyOnFailure,
|
||||||
&t.LastRunAt, &t.NextRunAt, &t.RunCount, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
stN(&t.LastRunAt), stN(&t.NextRunAt), &t.RunCount, st(&t.CreatedAt), st(&t.UpdatedAt)); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tasks = append(tasks, t)
|
tasks = append(tasks, t)
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ func (s *WorkflowStore) Create(ctx context.Context, w *models.Workflow) error {
|
|||||||
retention := jsonOrDefault(w.Retention, `{"mode":"archive"}`)
|
retention := jsonOrDefault(w.Retention, `{"mode":"archive"}`)
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
INSERT INTO workflows (id, team_id, name, slug, description, branding, entry_mode,
|
INSERT INTO workflows (id, team_id, name, slug, description, branding, entry_mode,
|
||||||
is_active, version, on_complete, retention, created_by, created_at, updated_at)
|
is_active, version, on_complete, retention, webhook_url, webhook_secret,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
created_by, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
w.ID, w.TeamID, w.Name, w.Slug, w.Description, branding, w.EntryMode,
|
w.ID, w.TeamID, w.Name, w.Slug, w.Description, branding, w.EntryMode,
|
||||||
boolToInt(w.IsActive), w.Version, jsonOrNull(w.OnComplete), retention,
|
boolToInt(w.IsActive), w.Version, jsonOrNull(w.OnComplete), retention,
|
||||||
|
nullIfEmpty(w.WebhookURL), nullIfEmpty(w.WebhookSecret),
|
||||||
w.CreatedBy, w.CreatedAt.Format(time.RFC3339), w.UpdatedAt.Format(time.RFC3339))
|
w.CreatedBy, w.CreatedAt.Format(time.RFC3339), w.UpdatedAt.Format(time.RFC3339))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -38,7 +40,8 @@ func (s *WorkflowStore) Create(ctx context.Context, w *models.Workflow) error {
|
|||||||
func (s *WorkflowStore) GetByID(ctx context.Context, id string) (*models.Workflow, error) {
|
func (s *WorkflowStore) GetByID(ctx context.Context, id string) (*models.Workflow, error) {
|
||||||
return s.scanOne(ctx, `
|
return s.scanOne(ctx, `
|
||||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||||
version, on_complete, retention, created_by, created_at, updated_at
|
version, on_complete, retention, webhook_url, webhook_secret,
|
||||||
|
created_by, created_at, updated_at
|
||||||
FROM workflows WHERE id = ?`, id)
|
FROM workflows WHERE id = ?`, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,12 +49,14 @@ func (s *WorkflowStore) GetBySlug(ctx context.Context, teamID *string, slug stri
|
|||||||
if teamID != nil {
|
if teamID != nil {
|
||||||
return s.scanOne(ctx, `
|
return s.scanOne(ctx, `
|
||||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||||
version, on_complete, retention, created_by, created_at, updated_at
|
version, on_complete, retention, webhook_url, webhook_secret,
|
||||||
|
created_by, created_at, updated_at
|
||||||
FROM workflows WHERE team_id = ? AND slug = ?`, *teamID, slug)
|
FROM workflows WHERE team_id = ? AND slug = ?`, *teamID, slug)
|
||||||
}
|
}
|
||||||
return s.scanOne(ctx, `
|
return s.scanOne(ctx, `
|
||||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||||
version, on_complete, retention, created_by, created_at, updated_at
|
version, on_complete, retention, webhook_url, webhook_secret,
|
||||||
|
created_by, created_at, updated_at
|
||||||
FROM workflows WHERE team_id IS NULL AND slug = ?`, slug)
|
FROM workflows WHERE team_id IS NULL AND slug = ?`, slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +92,14 @@ func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.Work
|
|||||||
sets = append(sets, "retention = ?")
|
sets = append(sets, "retention = ?")
|
||||||
args = append(args, string(*patch.Retention))
|
args = append(args, string(*patch.Retention))
|
||||||
}
|
}
|
||||||
|
if patch.WebhookURL != nil {
|
||||||
|
sets = append(sets, "webhook_url = ?")
|
||||||
|
args = append(args, *patch.WebhookURL)
|
||||||
|
}
|
||||||
|
if patch.WebhookSecret != nil {
|
||||||
|
sets = append(sets, "webhook_secret = ?")
|
||||||
|
args = append(args, *patch.WebhookSecret)
|
||||||
|
}
|
||||||
|
|
||||||
if len(sets) == 0 {
|
if len(sets) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -118,7 +131,8 @@ func (s *WorkflowStore) Delete(ctx context.Context, id string) error {
|
|||||||
func (s *WorkflowStore) ListForTeam(ctx context.Context, teamID string) ([]models.Workflow, error) {
|
func (s *WorkflowStore) ListForTeam(ctx context.Context, teamID string) ([]models.Workflow, error) {
|
||||||
return s.queryWorkflows(ctx, `
|
return s.queryWorkflows(ctx, `
|
||||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||||
version, on_complete, retention, created_by, created_at, updated_at
|
version, on_complete, retention, webhook_url, webhook_secret,
|
||||||
|
created_by, created_at, updated_at
|
||||||
FROM workflows WHERE team_id = ?
|
FROM workflows WHERE team_id = ?
|
||||||
ORDER BY name ASC`, teamID)
|
ORDER BY name ASC`, teamID)
|
||||||
}
|
}
|
||||||
@@ -126,7 +140,8 @@ func (s *WorkflowStore) ListForTeam(ctx context.Context, teamID string) ([]model
|
|||||||
func (s *WorkflowStore) ListGlobal(ctx context.Context) ([]models.Workflow, error) {
|
func (s *WorkflowStore) ListGlobal(ctx context.Context) ([]models.Workflow, error) {
|
||||||
return s.queryWorkflows(ctx, `
|
return s.queryWorkflows(ctx, `
|
||||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||||
version, on_complete, retention, created_by, created_at, updated_at
|
version, on_complete, retention, webhook_url, webhook_secret,
|
||||||
|
created_by, created_at, updated_at
|
||||||
FROM workflows WHERE team_id IS NULL
|
FROM workflows WHERE team_id IS NULL
|
||||||
ORDER BY name ASC`)
|
ORDER BY name ASC`)
|
||||||
}
|
}
|
||||||
@@ -160,18 +175,18 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var result []models.WorkflowStage
|
var result []models.WorkflowStage
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var st models.WorkflowStage
|
var stg models.WorkflowStage
|
||||||
var formTpl, transRules string
|
var formTpl, transRules string
|
||||||
var autoTrans int
|
var autoTrans int
|
||||||
if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name,
|
if err := rows.Scan(&stg.ID, &stg.WorkflowID, &stg.Ordinal, &stg.Name,
|
||||||
&st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.HistoryMode,
|
&stg.PersonaID, &stg.AssignmentTeamID, &formTpl, &stg.HistoryMode,
|
||||||
&autoTrans, &transRules, &st.CreatedAt); err != nil {
|
&autoTrans, &transRules, st(&stg.CreatedAt)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
st.FormTemplate = json.RawMessage(formTpl)
|
stg.FormTemplate = json.RawMessage(formTpl)
|
||||||
st.TransitionRules = json.RawMessage(transRules)
|
stg.TransitionRules = json.RawMessage(transRules)
|
||||||
st.AutoTransition = autoTrans != 0
|
stg.AutoTransition = autoTrans != 0
|
||||||
result = append(result, st)
|
result = append(result, stg)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
@@ -231,7 +246,7 @@ func (s *WorkflowStore) GetVersion(ctx context.Context, workflowID string, versi
|
|||||||
SELECT id, workflow_id, version_number, snapshot, created_at
|
SELECT id, workflow_id, version_number, snapshot, created_at
|
||||||
FROM workflow_versions WHERE workflow_id = ? AND version_number = ?`,
|
FROM workflow_versions WHERE workflow_id = ? AND version_number = ?`,
|
||||||
workflowID, versionNumber,
|
workflowID, versionNumber,
|
||||||
).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, &v.CreatedAt)
|
).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, st(&v.CreatedAt))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -247,7 +262,7 @@ func (s *WorkflowStore) GetLatestVersion(ctx context.Context, workflowID string)
|
|||||||
FROM workflow_versions WHERE workflow_id = ?
|
FROM workflow_versions WHERE workflow_id = ?
|
||||||
ORDER BY version_number DESC LIMIT 1`,
|
ORDER BY version_number DESC LIMIT 1`,
|
||||||
workflowID,
|
workflowID,
|
||||||
).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, &v.CreatedAt)
|
).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, st(&v.CreatedAt))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -261,11 +276,13 @@ func (s *WorkflowStore) scanOne(ctx context.Context, q string, args ...interface
|
|||||||
w := &models.Workflow{}
|
w := &models.Workflow{}
|
||||||
var branding, retention string
|
var branding, retention string
|
||||||
var onComplete sql.NullString
|
var onComplete sql.NullString
|
||||||
|
var webhookURL, webhookSecret sql.NullString
|
||||||
var isActive int
|
var isActive int
|
||||||
err := DB.QueryRowContext(ctx, q, args...).Scan(
|
err := DB.QueryRowContext(ctx, q, args...).Scan(
|
||||||
&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
|
&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
|
||||||
&w.EntryMode, &isActive, &w.Version, &onComplete, &retention,
|
&w.EntryMode, &isActive, &w.Version, &onComplete, &retention,
|
||||||
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt)
|
&webhookURL, &webhookSecret,
|
||||||
|
&w.CreatedBy, st(&w.CreatedAt), st(&w.UpdatedAt))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -275,6 +292,12 @@ func (s *WorkflowStore) scanOne(ctx context.Context, q string, args ...interface
|
|||||||
if onComplete.Valid {
|
if onComplete.Valid {
|
||||||
w.OnComplete = json.RawMessage(onComplete.String)
|
w.OnComplete = json.RawMessage(onComplete.String)
|
||||||
}
|
}
|
||||||
|
if webhookURL.Valid {
|
||||||
|
w.WebhookURL = webhookURL.String
|
||||||
|
}
|
||||||
|
if webhookSecret.Valid {
|
||||||
|
w.WebhookSecret = webhookSecret.String
|
||||||
|
}
|
||||||
return w, nil
|
return w, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,10 +312,12 @@ func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...in
|
|||||||
var w models.Workflow
|
var w models.Workflow
|
||||||
var branding, retention string
|
var branding, retention string
|
||||||
var onComplete sql.NullString
|
var onComplete sql.NullString
|
||||||
|
var webhookURL, webhookSecret sql.NullString
|
||||||
var isActive int
|
var isActive int
|
||||||
if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description,
|
if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description,
|
||||||
&branding, &w.EntryMode, &isActive, &w.Version, &onComplete,
|
&branding, &w.EntryMode, &isActive, &w.Version, &onComplete,
|
||||||
&retention, &w.CreatedBy, &w.CreatedAt, &w.UpdatedAt); err != nil {
|
&retention, &webhookURL, &webhookSecret,
|
||||||
|
&w.CreatedBy, st(&w.CreatedAt), st(&w.UpdatedAt)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
w.Branding = json.RawMessage(branding)
|
w.Branding = json.RawMessage(branding)
|
||||||
@@ -301,6 +326,12 @@ func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...in
|
|||||||
if onComplete.Valid {
|
if onComplete.Valid {
|
||||||
w.OnComplete = json.RawMessage(onComplete.String)
|
w.OnComplete = json.RawMessage(onComplete.String)
|
||||||
}
|
}
|
||||||
|
if webhookURL.Valid {
|
||||||
|
w.WebhookURL = webhookURL.String
|
||||||
|
}
|
||||||
|
if webhookSecret.Valid {
|
||||||
|
w.WebhookSecret = webhookSecret.String
|
||||||
|
}
|
||||||
result = append(result, w)
|
result = append(result, w)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
@@ -326,3 +357,10 @@ func jsonOrNull(b json.RawMessage) interface{} {
|
|||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nullIfEmpty(s string) interface{} {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|||||||
@@ -352,11 +352,12 @@ func TriggerWorkflowOnComplete(ctx context.Context, stores store.Stores, workflo
|
|||||||
|
|
||||||
// Bind first stage persona
|
// Bind first stage persona
|
||||||
if len(stages) > 0 && stages[0].PersonaID != nil {
|
if len(stages) > 0 && stages[0].PersonaID != nil {
|
||||||
_, _ = database.DB.ExecContext(ctx, database.Q(`
|
_ = stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{
|
||||||
INSERT INTO channel_participants (channel_id, participant_type, participant_id)
|
ChannelID: ch.ID,
|
||||||
VALUES ($1, 'persona', $2)
|
ParticipantType: "persona",
|
||||||
ON CONFLICT DO NOTHING
|
ParticipantID: *stages[0].PersonaID,
|
||||||
`), ch.ID, *stages[0].PersonaID)
|
Role: "member",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[workflow] on_complete: started chained workflow %s → %s (channel %s)",
|
log.Printf("[workflow] on_complete: started chained workflow %s → %s (channel %s)",
|
||||||
|
|||||||
Reference in New Issue
Block a user