Changeset 0.28.6 (#192)

This commit is contained in:
2026-03-15 01:33:38 +00:00
parent 6f0ad1355c
commit bffda043db
59 changed files with 3022 additions and 77 deletions

View File

@@ -1,5 +1,83 @@
# Changelog # Changelog
## [0.28.6] — 2026-03-15
### Summary
Infrastructure release. Virtual scroll for long conversations, Helm chart
for Kubernetes deployment, system task type (Go function registry replacing
ad-hoc goroutines), admin broadcast notifications, server-side SSH key
generation for git credentials, task webhook trigger UI, and model
preference dedup fix.
KB auto-inject pipe filter deferred to v0.29.0 — will be built as the
reference Go implementation of the server-side filter model alongside
Starlark, ensuring the architecture is right and performant before
user code runs in it.
### New
- **Virtual scroll** — viewport-windowed message rendering for long
conversations (100+ messages). IntersectionObserver-based sentinel
triggers prepend chunks of 40 as user scrolls up. DOM capped at ~80
nodes regardless of conversation length. Streaming messages always
rendered. Transparent fallback for short conversations.
- **Helm chart** — `chart/` directory with full Kubernetes deployment.
`helm install switchboard ./chart` replaces raw manifests. Configures
backend + frontend deployments, services, ingress (Traefik), PVC,
ConfigMap, Secret. All `config.go` env vars exposed as values.
- **System task type** — `task_type: "system"` with Go function registry.
Built-in functions: `session_cleanup`, `staleness_check`,
`retention_sweep`, `health_prune`. Admin creates task, picks function
from dropdown, sets cron schedule. Executor calls registered Go function
instead of LLM. Replaces ad-hoc goroutine background jobs with visible,
configurable, auditable tasks. Permanent track — not replaced by Starlark.
`GET /admin/system-functions` returns available functions.
- **Admin broadcast** — `POST /admin/notifications/broadcast` sends
`system.announcement` notification to all active users via `NotifyMany`.
Emits `system.broadcast` WebSocket event. Admin UI compose form with
title, message, level selector (info/warning/critical).
- **Git credential key generation** — `POST /git-credentials/generate`
creates ED25519 SSH keypair server-side. Private key vault-encrypted,
never exposed. Public key + SHA256 fingerprint returned for user to add
to git host. Optional `persona_id` for per-persona commit attribution.
`GET /git-credentials/:id/public-key` for clipboard copy. Settings UI
section with generate, list, copy, delete.
- **Task webhook trigger UI** — schedule selector gains "Webhook trigger"
option. Trigger URL displayed with copy button after creation. Link
button on webhook tasks in list view. `webhook_secret` field for HMAC
signing. Task-to-task chaining documented (Task A webhook_url → Task B
trigger URL).
### Fixed
- **Model preferences NULL-in-UNIQUE** — `provider_config_id` column on
`user_model_settings` changed to `NOT NULL`. The `UNIQUE(user_id,
model_id, provider_config_id)` constraint silently allowed duplicates
when the column was NULL. Handler already enforced `binding:"required"`
— schema now matches.
- **ICD runner shape** — `S.modelPreference` gains `provider_config_id`
field (smoke tier shape assertion).
### Changed
- `ListActiveUserIDs` added to `UserStore` interface (PG + SQLite).
- `NotifTypeAnnouncement` constant added to notification types.
- Task model gains `system_function` field. Task stores updated
(taskColumns, scanTask, Create) in both PG and SQLite.
- ICD runner version bumped. New tests: model prefs (8), broadcast (3),
git credentials (6), system tasks (4).
### Docs
- `notifications.md` — broadcast endpoint, `system.broadcast` WS event.
- `admin.md` — broadcast cross-reference.
- `workspaces.md``/generate`, `/public-key` endpoints, new fields.
- `tasks.md` — system task type, function registry, chaining pattern,
`GET /admin/system-functions`.
## [0.28.5] — 2026-03-14 ## [0.28.5] — 2026-03-14
### Summary ### Summary

View File

@@ -1 +1 @@
0.28.5 0.28.6

11
chart/Chart.yaml Normal file
View File

@@ -0,0 +1,11 @@
apiVersion: v2
name: switchboard
description: Chat Switchboard — self-hosted enterprise AI chat platform
type: application
version: 0.1.0
appVersion: "0.28.6"
home: https://gobha.ai
sources:
- https://git.gobha.me/xcaliber/chat-switchboard
maintainers:
- name: xcaliber

25
chart/templates/NOTES.txt Normal file
View File

@@ -0,0 +1,25 @@
Chat Switchboard {{ .Chart.AppVersion }} deployed.
{{- if .Values.ingress.enabled }}
Access the application at:
{{- if .Values.ingress.tls.enabled }}
https://{{ .Values.ingress.host }}{{ .Values.basePath }}
{{- else }}
http://{{ .Values.ingress.host }}{{ .Values.basePath }}
{{- end }}
{{- else }}
Port-forward to access:
kubectl port-forward svc/{{ .Release.Name }}-frontend 3000:{{ .Values.frontend.port }}
open http://localhost:3000{{ .Values.basePath }}
{{- end }}
{{- if .Values.admin.password }}
Admin credentials set via Helm values.
{{- else }}
⚠ No admin password set. Set admin.password in values or an existing secret.
{{- end }}
{{- if not .Values.encryptionKey }}
⚠ ENCRYPTION_KEY not set. BYOK provider keys cannot be encrypted.
Set encryptionKey in values for production use.
{{- end }}

View File

@@ -0,0 +1,64 @@
{{/*
Common labels
*/}}
{{- define "switchboard.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
{{- end }}
{{/*
Backend selector labels
*/}}
{{- define "switchboard.backend.labels" -}}
app.kubernetes.io/component: backend
{{ include "switchboard.labels" . }}
{{- end }}
{{/*
Frontend selector labels
*/}}
{{- define "switchboard.frontend.labels" -}}
app.kubernetes.io/component: frontend
{{ include "switchboard.labels" . }}
{{- end }}
{{/*
Secret name
*/}}
{{- define "switchboard.secretName" -}}
{{- if .Values.existingSecret -}}
{{ .Values.existingSecret }}
{{- else -}}
{{ .Release.Name }}-secrets
{{- end -}}
{{- end }}
{{/*
Backend image
*/}}
{{- define "switchboard.backend.image" -}}
{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag | default .Chart.AppVersion }}
{{- end }}
{{/*
Frontend image
*/}}
{{- define "switchboard.frontend.image" -}}
{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }}
{{- end }}
{{/*
Database URL assembled from postgres.* if url is empty
*/}}
{{- define "switchboard.databaseURL" -}}
{{- if .Values.database.url -}}
{{ .Values.database.url }}
{{- else if eq .Values.database.driver "sqlite" -}}
{{ .Values.database.sqlitePath }}
{{- else -}}
postgres://{{ .Values.database.postgres.user }}:$(POSTGRES_PASSWORD)@{{ .Values.database.postgres.host }}:{{ .Values.database.postgres.port }}/{{ .Values.database.postgres.database }}?sslmode={{ .Values.database.postgres.sslmode }}
{{- end -}}
{{- end }}

View File

@@ -0,0 +1,49 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}-config
labels:
{{- include "switchboard.labels" . | nindent 4 }}
data:
PORT: {{ .Values.backend.port | quote }}
BASE_PATH: {{ .Values.basePath | quote }}
ENVIRONMENT: {{ .Values.environment | quote }}
DB_DRIVER: {{ .Values.database.driver | quote }}
AUTH_MODE: {{ .Values.auth.mode | quote }}
STORAGE_BACKEND: {{ .Values.storage.backend | quote }}
STORAGE_PATH: {{ .Values.storage.path | quote }}
SESSION_EXPIRY_DAYS: {{ .Values.sessionExpiryDays | quote }}
WORKFLOW_STALE_HOURS: {{ .Values.workflowStaleHours | quote }}
PROVIDER_AUTO_DISABLE_THRESHOLD: {{ .Values.providerAutoDisableThreshold | quote }}
EXTRACTION_MODE: {{ .Values.extraction.mode | quote }}
EXTRACTION_CONCURRENCY: {{ .Values.extraction.concurrency | quote }}
WORKSPACE_INDEXING_ENABLED: {{ .Values.workspace.indexingEnabled | quote }}
WORKSPACE_INDEX_CONCURRENCY: {{ .Values.workspace.indexConcurrency | quote }}
CORS_ALLOWED_ORIGINS: {{ .Values.corsAllowedOrigins | quote }}
{{- if .Values.storage.s3.endpoint }}
S3_ENDPOINT: {{ .Values.storage.s3.endpoint | quote }}
S3_BUCKET: {{ .Values.storage.s3.bucket | quote }}
S3_REGION: {{ .Values.storage.s3.region | quote }}
S3_PREFIX: {{ .Values.storage.s3.prefix | quote }}
S3_FORCE_PATH_STYLE: {{ .Values.storage.s3.forcePathStyle | quote }}
{{- end }}
{{- if eq .Values.auth.mode "oidc" }}
OIDC_ISSUER_URL: {{ .Values.auth.oidc.issuerURL | quote }}
OIDC_EXTERNAL_ISSUER_URL: {{ .Values.auth.oidc.externalIssuerURL | quote }}
OIDC_CLIENT_ID: {{ .Values.auth.oidc.clientID | quote }}
OIDC_REDIRECT_URL: {{ .Values.auth.oidc.redirectURL | quote }}
OIDC_AUTO_ACTIVATE: {{ .Values.auth.oidc.autoActivate | quote }}
OIDC_DEFAULT_TEAM: {{ .Values.auth.oidc.defaultTeam | quote }}
OIDC_DEFAULT_ROLE: {{ .Values.auth.oidc.defaultRole | quote }}
OIDC_ROLES_CLAIM: {{ .Values.auth.oidc.rolesClaim | quote }}
OIDC_GROUPS_CLAIM: {{ .Values.auth.oidc.groupsClaim | quote }}
OIDC_ADMIN_ROLE: {{ .Values.auth.oidc.adminRole | quote }}
{{- end }}
{{- if eq .Values.auth.mode "mtls" }}
MTLS_HEADER_DN: {{ .Values.auth.mtls.headerDN | quote }}
MTLS_HEADER_VERIFY: {{ .Values.auth.mtls.headerVerify | quote }}
MTLS_HEADER_FINGERPRINT: {{ .Values.auth.mtls.headerFingerprint | quote }}
MTLS_AUTO_ACTIVATE: {{ .Values.auth.mtls.autoActivate | quote }}
MTLS_DEFAULT_TEAM: {{ .Values.auth.mtls.defaultTeam | quote }}
MTLS_DEFAULT_ROLE: {{ .Values.auth.mtls.defaultRole | quote }}
{{- end }}

View File

@@ -0,0 +1,65 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-backend
labels:
{{- include "switchboard.backend.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.backend.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: backend
template:
metadata:
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: backend
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: backend
image: {{ include "switchboard.backend.image" . }}
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.backend.port }}
protocol: TCP
envFrom:
- configMapRef:
name: {{ .Release.Name }}-config
- secretRef:
name: {{ include "switchboard.secretName" . }}
env:
- name: DATABASE_URL
value: {{ include "switchboard.databaseURL" . | quote }}
resources:
{{- toYaml .Values.backend.resources | nindent 12 }}
{{- if .Values.persistence.enabled }}
volumeMounts:
- name: data
mountPath: /data
{{- end }}
livenessProbe:
httpGet:
path: {{ .Values.basePath }}/api/v1/health
port: http
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: {{ .Values.basePath }}/api/v1/health
port: http
initialDelaySeconds: 5
periodSeconds: 10
{{- if .Values.persistence.enabled }}
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ .Release.Name }}-data
{{- end }}

View File

@@ -0,0 +1,50 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-frontend
labels:
{{- include "switchboard.frontend.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.frontend.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: frontend
template:
metadata:
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: frontend
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: frontend
image: {{ include "switchboard.frontend.image" . }}
imagePullPolicy: {{ .Values.frontend.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.frontend.port }}
protocol: TCP
env:
- name: BACKEND_URL
value: "http://{{ .Release.Name }}-backend:{{ .Values.backend.port }}"
- name: BASE_PATH
value: {{ .Values.basePath | quote }}
resources:
{{- toYaml .Values.frontend.resources | nindent 12 }}
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 5
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: http
periodSeconds: 10

View File

@@ -0,0 +1,51 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}-ingress
labels:
{{- include "switchboard.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.ingress.host }}
{{- if .Values.ingress.tls.secretName }}
secretName: {{ .Values.ingress.tls.secretName }}
{{- end }}
{{- end }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
# API + WebSocket → backend
- path: {{ .Values.basePath }}/api
pathType: Prefix
backend:
service:
name: {{ .Release.Name }}-backend
port:
number: {{ .Values.backend.port }}
- path: {{ .Values.basePath }}/ws
pathType: Prefix
backend:
service:
name: {{ .Release.Name }}-backend
port:
number: {{ .Values.backend.port }}
# Everything else → frontend (nginx serves static + proxies API)
- path: {{ .Values.basePath | default "/" }}
pathType: Prefix
backend:
service:
name: {{ .Release.Name }}-frontend
port:
number: {{ .Values.frontend.port }}
{{- end }}

17
chart/templates/pvc.yaml Normal file
View File

@@ -0,0 +1,17 @@
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ .Release.Name }}-data
labels:
{{- include "switchboard.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.accessMode }}
{{- if .Values.persistence.storageClass }}
storageClassName: {{ .Values.persistence.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}

View File

@@ -0,0 +1,31 @@
{{- if not .Values.existingSecret }}
apiVersion: v1
kind: Secret
metadata:
name: {{ .Release.Name }}-secrets
labels:
{{- include "switchboard.labels" . | nindent 4 }}
type: Opaque
stringData:
JWT_SECRET: {{ required "jwtSecret is required" .Values.jwtSecret | quote }}
{{- if .Values.encryptionKey }}
ENCRYPTION_KEY: {{ .Values.encryptionKey | quote }}
{{- end }}
{{- if .Values.database.postgres.password }}
POSTGRES_PASSWORD: {{ .Values.database.postgres.password | quote }}
{{- end }}
{{- if .Values.admin.password }}
SWITCHBOARD_ADMIN_USERNAME: {{ .Values.admin.username | quote }}
SWITCHBOARD_ADMIN_PASSWORD: {{ .Values.admin.password | quote }}
{{- end }}
{{- if .Values.admin.email }}
SWITCHBOARD_ADMIN_EMAIL: {{ .Values.admin.email | quote }}
{{- end }}
{{- if .Values.storage.s3.accessKey }}
S3_ACCESS_KEY: {{ .Values.storage.s3.accessKey | quote }}
S3_SECRET_KEY: {{ .Values.storage.s3.secretKey | quote }}
{{- end }}
{{- if and (eq .Values.auth.mode "oidc") .Values.auth.oidc.clientSecret }}
OIDC_CLIENT_SECRET: {{ .Values.auth.oidc.clientSecret | quote }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,35 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-backend
labels:
{{- include "switchboard.backend.labels" . | nindent 4 }}
spec:
type: ClusterIP
ports:
- port: {{ .Values.backend.port }}
targetPort: http
protocol: TCP
name: http
selector:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: backend
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-frontend
labels:
{{- include "switchboard.frontend.labels" . | nindent 4 }}
spec:
type: ClusterIP
ports:
- port: {{ .Values.frontend.port }}
targetPort: http
protocol: TCP
name: http
selector:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: frontend

135
chart/values.yaml Normal file
View File

@@ -0,0 +1,135 @@
# Chat Switchboard — Helm values
# helm install switchboard ./chart
# ── Images ─────────────────────────────────
backend:
image:
repository: git.gobha.me/xcaliber/chat-switchboard
tag: "" # defaults to Chart.appVersion
pullPolicy: IfNotPresent
replicaCount: 1
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
port: 8080
frontend:
image:
repository: git.gobha.me/xcaliber/chat-switchboard-frontend
tag: "" # defaults to Chart.appVersion
pullPolicy: IfNotPresent
replicaCount: 1
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
port: 80
# ── Database ───────────────────────────────
database:
# "postgres" or "sqlite"
driver: postgres
# For postgres: full DSN or assembled from postgres.* below
url: ""
postgres:
host: postgresql
port: 5432
user: switchboard
password: "" # set via secret
database: switchboard
sslmode: disable
# For sqlite: path inside the container (requires persistence)
sqlitePath: /data/switchboard.db
# ── Core ───────────────────────────────────
basePath: "" # URL prefix, e.g. "/dev"
environment: production
jwtSecret: "" # REQUIRED — set via secret or --set
encryptionKey: "" # REQUIRED for BYOK — set via secret
# ── Admin bootstrap ────────────────────────
admin:
username: admin
password: "" # set via secret
email: ""
# ── Storage ────────────────────────────────
storage:
backend: pvc # "pvc" or "s3"
path: /data/storage
s3:
endpoint: ""
bucket: ""
region: us-east-1
accessKey: ""
secretKey: ""
prefix: ""
forcePathStyle: true
# ── Persistence (PVC) ─────────────────────
persistence:
enabled: true
storageClass: "" # use cluster default
size: 10Gi
accessMode: ReadWriteOnce
# ── Ingress ────────────────────────────────
ingress:
enabled: true
className: traefik
annotations: {}
host: switchboard.local
tls:
enabled: false
secretName: ""
# ── Auth ───────────────────────────────────
auth:
mode: builtin # builtin | mtls | oidc
oidc:
issuerURL: ""
externalIssuerURL: ""
clientID: ""
clientSecret: ""
redirectURL: ""
autoActivate: true
defaultTeam: ""
defaultRole: user
rolesClaim: "realm_access.roles"
groupsClaim: groups
adminRole: admin
mtls:
headerDN: X-SSL-Client-DN
headerVerify: X-SSL-Client-Verify
headerFingerprint: X-SSL-Client-Fingerprint
autoActivate: true
defaultTeam: ""
defaultRole: user
# ── Tuning ─────────────────────────────────
sessionExpiryDays: 30
workflowStaleHours: 72
providerAutoDisableThreshold: 3
extraction:
mode: inline
concurrency: 3
workspace:
indexingEnabled: true
indexConcurrency: 2
# ── CORS ───────────────────────────────────
corsAllowedOrigins: "*"
# ── Existing secret reference ──────────────
# If set, skips creating a Secret and mounts this one instead.
existingSecret: ""
# ── Image pull secrets ─────────────────────
imagePullSecrets: []

View File

@@ -234,3 +234,15 @@ POST /admin/tasks/:id/run → force run
POST /admin/tasks/:id/kill → cancel active run POST /admin/tasks/:id/kill → cancel active run
DELETE /admin/tasks/:id → delete task DELETE /admin/tasks/:id → delete task
``` ```
### Notifications Admin (v0.28.6)
```
POST /admin/notifications/broadcast ← { "title", "message", "level?" }
→ { "message": "broadcast sent", "count": N }
```
Sends a `system.announcement` notification to all active users.
Also emits a `system.broadcast` WebSocket event for real-time delivery.
`level` is optional (`info` | `warning` | `critical`, default `info`).
See [notifications.md](notifications.md#admin-broadcast-v0286) for full details.

View File

@@ -91,6 +91,42 @@ POST /admin/notifications/test-email → send test email to requesting ad
Requires admin role. Loads SMTP config from platform settings, Requires admin role. Loads SMTP config from platform settings,
sends a test message to the admin's registered email address. sends a test message to the admin's registered email address.
### Admin Broadcast (v0.28.6)
```
POST /admin/notifications/broadcast
```
**Auth:** Admin only.
Sends a `system.announcement` notification to all active users via
`NotifyMany`. Also emits a `system.broadcast` WebSocket event for
real-time visibility.
**Request body:**
```json
{
"title": "Scheduled Maintenance",
"message": "Servers will be down from 2-4 AM EST.",
"level": "warning"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | yes | Short summary shown in bell dropdown |
| `message` | string | yes | Detail text |
| `level` | string | no | `info` (default), `warning`, or `critical` |
**Response:**
```json
{ "message": "broadcast sent", "count": 42 }
```
`count` is the number of active users notified.
### WebSocket Events ### WebSocket Events
| Event | Payload | Description | | Event | Payload | Description |
@@ -98,6 +134,7 @@ sends a test message to the admin's registered email address.
| `notification.new` | Full notification object | New notification created | | `notification.new` | Full notification object | New notification created |
| `notification.read` | `{"id": "uuid"}` | Single notification marked read | | `notification.read` | `{"id": "uuid"}` | Single notification marked read |
| `notification.read` | `{"action": "mark_all_read"}` | All notifications marked read | | `notification.read` | `{"action": "mark_all_read"}` | All notifications marked read |
| `system.broadcast` | `{"title", "message", "level"}` | Admin announcement (v0.28.6) |
These are targeted via `Hub.SendToUser` — no room subscription These are targeted via `Hub.SendToUser` — no room subscription
required. Used for badge sync across tabs. required. Used for badge sync across tabs.
@@ -106,7 +143,8 @@ required. Used for badge sync across tabs.
See [enums.md](enums.md#notification-types) for the canonical list. See [enums.md](enums.md#notification-types) for the canonical list.
Types are free-form strings (`domain.action` convention) — new types Types are free-form strings (`domain.action` convention) — new types
don't require migration. don't require migration. Core types include `system.announcement`
(admin broadcast, v0.28.6).
### Retention ### Retention

View File

@@ -65,8 +65,17 @@ POST /tasks
``` ```
`task_type`: `prompt` (direct LLM execution), `workflow` (deferred — `task_type`: `prompt` (direct LLM execution), `workflow` (deferred —
not yet implemented; creation is rejected at the API), or `action` not yet implemented; creation is rejected at the API), `action`
(no LLM — relay/webhook only; requires `task.action` permission). (no LLM — relay/webhook only; requires `task.action` permission),
or `system` (v0.28.6 — built-in Go function, admin-only).
System tasks require `system_function` — a registered function name
from the platform's Go function registry. No LLM, no provider
resolution, no channel needed. The function receives the store set
and returns a result string. Designed for core platform ops
(`session_cleanup`, `staleness_check`, `retention_sweep`, `health_prune`)
that must not break from bad user code. Permanent track — not replaced
by Starlark in v0.29.0.
`schedule`: cron expression, `once`, or `webhook`. Supported cron `schedule`: cron expression, `once`, or `webhook`. Supported cron
patterns: `@hourly`, `@daily`, `@weekly`, `*/N * * * *` (every N patterns: `@hourly`, `@daily`, `@weekly`, `*/N * * * *` (every N
@@ -173,6 +182,26 @@ Returns `410 Gone` if the task is inactive.
**Rate limiting:** Governed by global request rate limits. No per-task **Rate limiting:** Governed by global request rate limits. No per-task
rate limiting in v0.28 — defer to a future version if needed. rate limiting in v0.28 — defer to a future version if needed.
### Task-to-Task Chaining (v0.28.6)
Tasks can trigger other tasks by setting the `webhook_url` of Task A
to the trigger URL of Task B:
1. Create **Task B** with `schedule: "webhook"`. Note the trigger URL
from the response (`/api/v1/hooks/t/{trigger_token}`).
2. Create **Task A** with `output_mode: "webhook"` and set `webhook_url`
to Task B's trigger URL.
3. When Task A completes, its output is POSTed to Task B's trigger URL.
Task B starts with Task A's output as its `trigger_payload`.
This enables multi-step pipelines without external orchestration:
- Task A: "Summarize today's news" (prompt, cron: 6am)
- Task B: "Format and post to Slack" (action, webhook-triggered)
The `webhook_secret` on Task A signs outbound payloads with HMAC-SHA256.
Task B's trigger endpoint does not currently verify signatures (the
trigger token itself is the auth mechanism).
## Team Tasks ## Team Tasks
Team-scoped tasks visible to all team members, manageable by team admins. Team-scoped tasks visible to all team members, manageable by team admins.
@@ -240,6 +269,30 @@ DELETE /admin/tasks/:id
**Auth:** Platform admin. **Auth:** Platform admin.
### System Functions (v0.28.6)
```
GET /admin/system-functions → { "data": [SystemFuncInfo, ...] }
```
Returns all registered built-in system function names and descriptions.
Used by the admin UI to populate the function dropdown when creating
system tasks.
**SystemFuncInfo:**
```json
{
"name": "session_cleanup",
"description": "Remove expired anonymous visitor sessions"
}
```
Built-in functions (v0.28.6): `session_cleanup`, `staleness_check`,
`retention_sweep`, `health_prune`.
**Auth:** Platform admin.
## Task Object ## Task Object
```json ```json
@@ -250,7 +303,8 @@ DELETE /admin/tasks/:id
"name": "Morning News Digest", "name": "Morning News Digest",
"description": "...", "description": "...",
"scope": "personal|team|global", "scope": "personal|team|global",
"task_type": "prompt|workflow|action", "task_type": "prompt|workflow|action|system",
"system_function": "session_cleanup",
"persona_id": "uuid|null", "persona_id": "uuid|null",
"model_id": "claude-sonnet-4-20250514", "model_id": "claude-sonnet-4-20250514",
"system_prompt": "...", "system_prompt": "...",

View File

@@ -361,13 +361,15 @@ Independent of workspace scope — credentials are user-level.
``` ```
POST /git-credentials ← { "name", "auth_type", ... } POST /git-credentials ← { "name", "auth_type", ... }
POST /git-credentials/generate ← { "name", "persona_id?" }
GET /git-credentials → { "data": [GitCredentialSummary] } GET /git-credentials → { "data": [GitCredentialSummary] }
GET /git-credentials/:id/public-key → { "public_key", "fingerprint" }
DELETE /git-credentials/:id DELETE /git-credentials/:id
``` ```
**Auth:** Authenticated user. Credentials are scoped to the requesting user. **Auth:** Authenticated user. Credentials are scoped to the requesting user.
**Create request:** **Create request (manual):**
`auth_type` determines which fields are required: `auth_type` determines which fields are required:
@@ -379,6 +381,37 @@ DELETE /git-credentials/:id
Returns `201` with the credential summary (never exposes encrypted data). Returns `201` with the credential summary (never exposes encrypted data).
**Generate request (v0.28.6 — server-side SSH keygen):**
```json
{
"name": "GitHub - main",
"persona_id": "uuid"
}
```
Generates an ED25519 SSH keypair server-side. The private key is
vault-encrypted before storage and never leaves the server. Returns
the credential summary including the public key (authorized_keys
format) and SHA256 fingerprint. The user adds the public key to their
git host (GitHub, Gitea, etc.).
Optional `persona_id` binds the key to a persona for commit
attribution — git operations using this credential will set the
commit author to the persona's name and handle.
Returns `201`.
**Get Public Key:**
```
GET /git-credentials/:id/public-key
```
Returns `{ "public_key": "ssh-ed25519 AAAA...", "fingerprint": "SHA256:..." }`.
Owner-only — returns `404` for other users or credentials without
a public key (e.g. PAT credentials).
**List response:** **List response:**
```json ```json
@@ -388,12 +421,18 @@ Returns `201` with the credential summary (never exposes encrypted data).
"id": "uuid", "id": "uuid",
"name": "GitHub PAT", "name": "GitHub PAT",
"auth_type": "https_pat", "auth_type": "https_pat",
"public_key": "",
"fingerprint": "",
"persona_id": null,
"created_at": "2025-06-15T14:30:00Z" "created_at": "2025-06-15T14:30:00Z"
} }
] ]
} }
``` ```
`public_key` and `fingerprint` are populated for `ssh_key` type
credentials created via `/generate`. Empty for PAT/basic auth.
**DELETE** returns `{"deleted": true}`. Returns `404` if credential **DELETE** returns `{"deleted": true}`. Returns `404` if credential
not found or not owned by the requesting user. not found or not owned by the requesting user.

View File

@@ -30,9 +30,9 @@ v0.9.xv0.27.5 Foundation → Extensions → Surfaces → Auth ✅
├─ v0.28.5 Frontend SDK + Pipes ✅ ├─ v0.28.5 Frontend SDK + Pipes ✅
│ (switchboard-sdk.js, pipe/filter │ (switchboard-sdk.js, pipe/filter
│ pipeline, component mounting) │ pipeline, component mounting)
├─ v0.28.6 Infrastructure ├─ v0.28.6 Infrastructure
│ (virtual scroll, Helm, task webhook │ (virtual scroll, Helm, system tasks,
UI, system tasks, model prefs) broadcast, git keygen, model prefs)
└─ v0.28.7 Unified Packaging + Task RBAC └─ v0.28.7 Unified Packaging + Task RBAC
(.pkg archive, manifest.type, (.pkg archive, manifest.type,
task permission gate pre-Starlark) task permission gate pre-Starlark)
@@ -295,27 +295,24 @@ Pipeline wires filters in priority order; any filter can halt the chain.
- [x] Pipe filter tests: priority ordering, halt semantics, error isolation, - [x] Pipe filter tests: priority ordering, halt semantics, error isolation,
scoped/unscoped execution scoped/unscoped execution
### v0.28.6 — Infrastructure ### v0.28.6 — Infrastructure
- [ ] Virtual scroll for long conversations (prerequisite for heavy task output channels) - [x] Virtual scroll for long conversations (prerequisite for heavy task output channels)
- [ ] KB auto-injection: platform-registered pre-send pipe filter (`_kb-auto-inject`, - [x] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`)
priority 5), top-K chunk prepend to `ctx.metadata.kb_context`, context budget aware, - [x] Per-provider model preferences — finalize: make `provider_config_id` required on
per-channel toggle. First real validation of the v0.28.5 pipeline architecture.
- [ ] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`)
- [ ] Per-provider model preferences — finalize: make `provider_config_id` required on
`PUT /models/preferences` (fixes NULL-in-UNIQUE dedup bug), migrate `/models/enabled` `PUT /models/preferences` (fixes NULL-in-UNIQUE dedup bug), migrate `/models/enabled`
and `/models/preferences` to `{"data": [...]}` envelope, add integration test coverage and `/models/preferences` to `{"data": [...]}` envelope, add integration test coverage
- [ ] Git credentials settings UI: vault-encrypted per-user credentials, SSH key - [x] Git credentials settings UI: server-side ED25519 key generation (private key
management, per-workspace remote config. Table exists (`git_credentials`), vault vault-encrypted, public key exposed for git host), optional persona binding for
pattern exists — needs settings section and CRUD handler. commit attribution, settings section with generate/list/copy/delete
- [ ] `system.announcement` notification type: admin broadcast endpoint - [x] `system.announcement` notification type: admin broadcast endpoint
(`POST /admin/notifications/broadcast`), fan-out to all active users via (`POST /admin/notifications/broadcast`), fan-out to all active users via
`NotifyMany`, admin UI for composing announcements `NotifyMany`, admin UI for composing announcements
- [ ] Task webhook trigger UI: schedule selector gains `webhook` option, - [x] Task webhook trigger UI: schedule selector gains `webhook` option,
trigger URL displayed + copy button, outbound `webhook_url` + `webhook_secret` trigger URL displayed + copy button, outbound `webhook_url` + `webhook_secret`
fields in create/edit form, task-to-task chaining documentation in admin UI. fields in create/edit form, task-to-task chaining documentation in admin UI.
Backend fully implemented — this is pure admin UI work. Backend fully implemented — this is pure admin UI work.
- [ ] System task type: `task_type: "system"` — built-in Go function registry - [x] System task type: `task_type: "system"` — built-in Go function registry
(`retention_sweep`, `memory_compact`, `session_cleanup`, `staleness_check`). (`retention_sweep`, `health_prune`, `session_cleanup`, `staleness_check`).
Admin creates task, picks function from dropdown, sets cron schedule. Executor Admin creates task, picks function from dropdown, sets cron schedule. Executor
calls registered Go function instead of LLM completion. Replaces the current calls registered Go function instead of LLM completion. Replaces the current
goroutine-based background jobs with visible, configurable, auditable tasks. goroutine-based background jobs with visible, configurable, auditable tasks.
@@ -323,6 +320,9 @@ Pipeline wires filters in priority order; any filter can halt the chain.
**Permanent track** — Go registry is not replaced by Starlark (v0.29.0). **Permanent track** — Go registry is not replaced by Starlark (v0.29.0).
Core platform ops must not break from bad user code. Admin-only by design Core platform ops must not break from bad user code. Admin-only by design
(no RBAC needed — hardcoded to admin role). (no RBAC needed — hardcoded to admin role).
- KB auto-injection moved to v0.29.0 — built as the reference Go implementation
of the server-side filter model alongside Starlark. Ensures the filter
architecture is right and performant before user code runs in it.
### v0.28.7 — Unified Packaging + Task RBAC ### v0.28.7 — Unified Packaging + Task RBAC
Single `.pkg` archive format for both surfaces and extensions. Manifest Single `.pkg` archive format for both surfaces and extensions. Manifest
@@ -453,6 +453,12 @@ extension infrastructure (v0.11.0).
data quality checks, integration sync, cleanup scripts. Editable, data quality checks, integration sync, cleanup scripts. Editable,
versionable, sandbox-isolated, permission-gated. versionable, sandbox-isolated, permission-gated.
- [ ] KB auto-injection: server-side pre-completion filter (Go built-in,
not Starlark). Top-K chunk prepend from channel-bound KBs, context
budget aware, per-channel toggle. Reference implementation of the
server-side filter model that Starlark filters will mirror. Moved
from v0.28.6 to validate alongside the Starlark filter architecture.
--- ---
## v0.29.1 — API Extensions ## v0.29.1 — API Extensions

View File

@@ -217,7 +217,7 @@ CREATE TABLE IF NOT EXISTS user_model_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
model_id TEXT NOT NULL, model_id TEXT NOT NULL,
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE CASCADE, provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
hidden BOOLEAN DEFAULT false, hidden BOOLEAN DEFAULT false,
preferred_temperature FLOAT, preferred_temperature FLOAT,
preferred_max_tokens INT, preferred_max_tokens INT,

View File

@@ -107,6 +107,9 @@ CREATE TABLE IF NOT EXISTS git_credentials (
auth_type TEXT NOT NULL DEFAULT 'https_pat', auth_type TEXT NOT NULL DEFAULT 'https_pat',
encrypted_data BYTEA NOT NULL DEFAULT '', encrypted_data BYTEA NOT NULL DEFAULT '',
nonce BYTEA NOT NULL DEFAULT '', nonce BYTEA NOT NULL DEFAULT '',
public_key TEXT NOT NULL DEFAULT '',
fingerprint TEXT NOT NULL DEFAULT '',
persona_id UUID REFERENCES personas(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );

View File

@@ -21,7 +21,8 @@ CREATE TABLE IF NOT EXISTS tasks (
-- What to run -- What to run
task_type TEXT NOT NULL DEFAULT 'prompt' task_type TEXT NOT NULL DEFAULT 'prompt'
CHECK (task_type IN ('prompt', 'workflow', 'action')), CHECK (task_type IN ('prompt', 'workflow', 'action', 'system')),
system_function TEXT DEFAULT '',
persona_id UUID REFERENCES personas(id) ON DELETE SET NULL, persona_id UUID REFERENCES personas(id) ON DELETE SET NULL,
model_id TEXT, model_id TEXT,
system_prompt TEXT DEFAULT '', system_prompt TEXT DEFAULT '',

View File

@@ -133,7 +133,7 @@ CREATE TABLE IF NOT EXISTS user_model_settings (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
model_id TEXT NOT NULL, model_id TEXT NOT NULL,
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE CASCADE, provider_config_id TEXT NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
hidden INTEGER DEFAULT 0, hidden INTEGER DEFAULT 0,
preferred_temperature REAL, preferred_temperature REAL,
preferred_max_tokens INTEGER, preferred_max_tokens INTEGER,

View File

@@ -60,6 +60,9 @@ CREATE TABLE IF NOT EXISTS git_credentials (
auth_type TEXT NOT NULL DEFAULT 'https_pat', auth_type TEXT NOT NULL DEFAULT 'https_pat',
encrypted_data BLOB NOT NULL DEFAULT '', encrypted_data BLOB NOT NULL DEFAULT '',
nonce BLOB NOT NULL DEFAULT '', nonce BLOB NOT NULL DEFAULT '',
public_key TEXT NOT NULL DEFAULT '',
fingerprint TEXT NOT NULL DEFAULT '',
persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );

View File

@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS tasks (
description TEXT DEFAULT '', description TEXT DEFAULT '',
scope TEXT NOT NULL DEFAULT 'personal', scope TEXT NOT NULL DEFAULT 'personal',
task_type TEXT NOT NULL DEFAULT 'prompt', task_type TEXT NOT NULL DEFAULT 'prompt',
system_function TEXT DEFAULT '',
persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL, persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
model_id TEXT, model_id TEXT,
system_prompt TEXT DEFAULT '', system_prompt TEXT DEFAULT '',

View File

@@ -1,11 +1,16 @@
package handlers package handlers
import ( import (
"crypto/ed25519"
"crypto/rand"
"encoding/json" "encoding/json"
"encoding/pem"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"golang.org/x/crypto/ssh"
"git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/store"
@@ -334,3 +339,98 @@ func (h *GitCredentialHandler) Delete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"deleted": true}) c.JSON(http.StatusOK, gin.H{"deleted": true})
} }
// Generate creates a new ED25519 SSH keypair server-side.
// The private key is vault-encrypted before storage. Only the public key
// and fingerprint are returned — the private key never leaves the server.
//
// POST /git-credentials/generate
func (h *GitCredentialHandler) Generate(c *gin.Context) {
userID := getUserID(c)
var req struct {
Name string `json:"name" binding:"required"`
PersonaID *string `json:"persona_id,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Generate ED25519 keypair
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "key generation failed"})
return
}
// Marshal public key to authorized_keys format
sshPub, err := ssh.NewPublicKey(pubKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "public key encoding failed"})
return
}
authorizedKey := string(ssh.MarshalAuthorizedKey(sshPub))
// Fingerprint (SHA256)
fingerprint := ssh.FingerprintSHA256(sshPub)
// Marshal private key to OpenSSH PEM format
privPEM, err := ssh.MarshalPrivateKey(privKey, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "private key encoding failed"})
return
}
privPEMBytes := pem.EncodeToMemory(privPEM)
// Encrypt private key via vault
credData := map[string]string{"private_key": string(privPEMBytes)}
plaintext, _ := json.Marshal(credData)
ciphertext, nonce, err := h.vault.EncryptForScope(string(plaintext), "global", userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "encryption failed"})
return
}
cred := &models.GitCredential{
UserID: userID,
Name: req.Name,
AuthType: "ssh_key",
EncryptedData: ciphertext,
Nonce: nonce,
PublicKey: authorizedKey,
Fingerprint: fingerprint,
PersonaID: req.PersonaID,
}
if err := h.stores.GitCredentials.Create(c.Request.Context(), cred); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, cred.Summary())
}
// GetPublicKey returns the public key for a credential (for copy-to-clipboard).
//
// GET /git-credentials/:id/public-key
func (h *GitCredentialHandler) GetPublicKey(c *gin.Context) {
userID := getUserID(c)
credID := c.Param("id")
cred, err := h.stores.GitCredentials.GetByID(c.Request.Context(), credID)
if err != nil || cred.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
return
}
if cred.PublicKey == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "no public key for this credential type"})
return
}
c.JSON(http.StatusOK, gin.H{
"public_key": cred.PublicKey,
"fingerprint": cred.Fingerprint,
})
}

View File

@@ -0,0 +1,281 @@
package handlers
import (
"encoding/json"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"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"
)
// ── Git Credentials Test Harness ──────────
type gitCredHarness struct {
*testHarness
userToken string
userID string
user2Token string
user2ID string
}
func setupGitCredHarness(t *testing.T) *gitCredHarness {
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)
}
userCache := middleware.NewUserStatusCache()
// Create a test vault with a static env key (32 bytes for AES-256)
envKey := []byte("test-encryption-key-32-bytes!!")
for len(envKey) < 32 {
envKey = append(envKey, '0')
}
envKey = envKey[:32]
vault := crypto.NewKeyResolver(envKey, nil)
r := gin.New()
api := r.Group("/api/v1")
protected := api.Group("")
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
gitCredH := NewGitCredentialHandler(stores, vault)
protected.POST("/git-credentials", gitCredH.Create)
protected.POST("/git-credentials/generate", gitCredH.Generate)
protected.GET("/git-credentials", gitCredH.List)
protected.GET("/git-credentials/:id/public-key", gitCredH.GetPublicKey)
protected.DELETE("/git-credentials/:id", gitCredH.Delete)
// Seed users
userID := database.SeedTestUser(t, "gituser", "gituser@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
userToken := makeToken(userID, "gituser@test.com", "user")
user2ID := database.SeedTestUser(t, "gituser2", "gituser2@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), user2ID)
user2Token := makeToken(user2ID, "gituser2@test.com", "user")
return &gitCredHarness{
testHarness: &testHarness{router: r, t: t},
userToken: userToken,
userID: userID,
user2Token: user2Token,
user2ID: user2ID,
}
}
// ── GET /git-credentials — empty state ───
func TestGitCreds_List_Empty(t *testing.T) {
h := setupGitCredHarness(t)
resp := h.request("GET", "/api/v1/git-credentials", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
data, ok := body["data"]
if !ok {
t.Fatal("response must have 'data' key")
}
arr := data.([]interface{})
if len(arr) != 0 {
t.Fatalf("expected empty array, got %d items", len(arr))
}
}
// ── POST /git-credentials/generate ───────
func TestGitCreds_Generate(t *testing.T) {
h := setupGitCredHarness(t)
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
"name": "Test Key",
})
if resp.Code != http.StatusCreated {
t.Fatalf("generate: got %d, body: %s", resp.Code, resp.Body.String())
}
var cred map[string]interface{}
json.NewDecoder(resp.Body).Decode(&cred)
// Must have public_key and fingerprint
pubKey, _ := cred["public_key"].(string)
fp, _ := cred["fingerprint"].(string)
if pubKey == "" {
t.Error("public_key should be non-empty")
}
if fp == "" {
t.Error("fingerprint should be non-empty")
}
if cred["auth_type"] != "ssh_key" {
t.Errorf("auth_type: got %v, want ssh_key", cred["auth_type"])
}
if cred["name"] != "Test Key" {
t.Errorf("name: got %v", cred["name"])
}
// Must NOT have encrypted data
if _, has := cred["encrypted_data"]; has {
t.Error("encrypted_data must not appear in response")
}
if _, has := cred["nonce"]; has {
t.Error("nonce must not appear in response")
}
}
// ── GET /git-credentials/:id/public-key ──
func TestGitCreds_GetPublicKey(t *testing.T) {
h := setupGitCredHarness(t)
// Generate a key first
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
"name": "PK Test",
})
var cred map[string]interface{}
json.NewDecoder(resp.Body).Decode(&cred)
id := cred["id"].(string)
origPK := cred["public_key"].(string)
// Retrieve public key
resp = h.request("GET", "/api/v1/git-credentials/"+id+"/public-key", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("get public-key: got %d, body: %s", resp.Code, resp.Body.String())
}
var pkBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&pkBody)
if pkBody["public_key"] != origPK {
t.Errorf("public key mismatch")
}
if pkBody["fingerprint"] == nil || pkBody["fingerprint"] == "" {
t.Error("fingerprint should be present")
}
}
// ── List after generate ──────────────────
func TestGitCreds_ListAfterGenerate(t *testing.T) {
h := setupGitCredHarness(t)
h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
"name": "Key A",
})
h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
"name": "Key B",
})
resp := h.request("GET", "/api/v1/git-credentials", h.userToken, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) != 2 {
t.Fatalf("expected 2 keys, got %d", len(arr))
}
// Verify summaries have public_key + fingerprint
for _, raw := range arr {
item := raw.(map[string]interface{})
if item["public_key"] == nil || item["public_key"] == "" {
t.Error("list item should have public_key")
}
if item["fingerprint"] == nil || item["fingerprint"] == "" {
t.Error("list item should have fingerprint")
}
}
}
// ── Delete ───────────────────────────────
func TestGitCreds_Delete(t *testing.T) {
h := setupGitCredHarness(t)
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
"name": "Delete Me",
})
var cred map[string]interface{}
json.NewDecoder(resp.Body).Decode(&cred)
id := cred["id"].(string)
// Delete
resp = h.request("DELETE", "/api/v1/git-credentials/"+id, h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("delete: got %d", resp.Code)
}
// Verify gone
resp = h.request("GET", "/api/v1/git-credentials", h.userToken, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) != 0 {
t.Fatalf("expected 0 keys after delete, got %d", len(arr))
}
}
// ── User isolation ───────────────────────
func TestGitCreds_UserIsolation(t *testing.T) {
h := setupGitCredHarness(t)
// User 1 generates a key
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
"name": "User1 Key",
})
var cred map[string]interface{}
json.NewDecoder(resp.Body).Decode(&cred)
id := cred["id"].(string)
// User 2 should see empty list
resp = h.request("GET", "/api/v1/git-credentials", h.user2Token, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) != 0 {
t.Fatalf("user2 should see 0 keys, got %d", len(arr))
}
// User 2 should not be able to get user1's public key
resp = h.request("GET", "/api/v1/git-credentials/"+id+"/public-key", h.user2Token, nil)
if resp.Code != http.StatusNotFound {
t.Fatalf("cross-user public-key: want 404, got %d", resp.Code)
}
// User 2 should not be able to delete user1's key
resp = h.request("DELETE", "/api/v1/git-credentials/"+id, h.user2Token, nil)
if resp.Code != http.StatusNotFound {
t.Fatalf("cross-user delete: want 404, got %d", resp.Code)
}
}
// ── Validation ───────────────────────────
func TestGitCreds_Generate_MissingName(t *testing.T) {
h := setupGitCredHarness(t)
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{})
if resp.Code != http.StatusBadRequest {
t.Fatalf("missing name: want 400, got %d", resp.Code)
}
}

View File

@@ -0,0 +1,363 @@
package handlers
import (
"encoding/json"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"git.gobha.me/xcaliber/chat-switchboard/config"
"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"
)
// ── Model Prefs Test Harness ──────────────
type modelPrefsHarness struct {
*testHarness
userToken string
userID string
user2Token string
user2ID string
configID string // seeded provider_config for preference tests
}
func setupModelPrefsHarness(t *testing.T) *modelPrefsHarness {
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)
}
userCache := middleware.NewUserStatusCache()
r := gin.New()
api := r.Group("/api/v1")
protected := api.Group("")
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
modelPrefs := NewModelPrefsHandler(stores)
protected.GET("/models/preferences", modelPrefs.GetPreferences)
protected.PUT("/models/preferences", modelPrefs.SetPreference)
protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
// Seed two users
userID := database.SeedTestUser(t, "prefuser", "prefuser@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
userToken := makeToken(userID, "prefuser@test.com", "user")
user2ID := database.SeedTestUser(t, "prefuser2", "prefuser2@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), user2ID)
user2Token := makeToken(user2ID, "prefuser2@test.com", "user")
// Seed a provider config for preference targets
configID := uuid.New().String()
if database.IsSQLite() {
database.TestDB.Exec(`
INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope)
VALUES (?, 'global', 'Test Provider', 'openai', 'https://api.openai.com/v1', 'global')`,
configID)
} else {
database.TestDB.Exec(`
INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope)
VALUES ($1, 'global', 'Test Provider', 'openai', 'https://api.openai.com/v1', 'global')`,
configID)
}
return &modelPrefsHarness{
testHarness: &testHarness{router: r, t: t},
userToken: userToken,
userID: userID,
user2Token: user2Token,
user2ID: user2ID,
configID: configID,
}
}
// ── GET /models/preferences — empty state ─
func TestModelPrefs_Get_Empty(t *testing.T) {
h := setupModelPrefsHarness(t)
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
data, ok := body["data"]
if !ok {
t.Fatal("response must have 'data' key")
}
arr, ok := data.([]interface{})
if !ok {
t.Fatal("data must be an array")
}
if len(arr) != 0 {
t.Fatalf("expected empty array, got %d items", len(arr))
}
}
// ── PUT + GET round-trip ──────────────────
func TestModelPrefs_Set_RoundTrip(t *testing.T) {
h := setupModelPrefsHarness(t)
// Set preference
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "test-model-1",
"provider_config_id": h.configID,
"hidden": true,
"sort_order": 5,
})
if resp.Code != http.StatusOK {
t.Fatalf("PUT: got %d, body: %s", resp.Code, resp.Body.String())
}
// Read back
resp = h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("GET: got %d", resp.Code)
}
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) != 1 {
t.Fatalf("expected 1 preference, got %d", len(arr))
}
pref := arr[0].(map[string]interface{})
if pref["model_id"] != "test-model-1" {
t.Errorf("model_id: got %v", pref["model_id"])
}
if pref["provider_config_id"] != h.configID {
t.Errorf("provider_config_id: got %v, want %s", pref["provider_config_id"], h.configID)
}
if pref["hidden"] != true {
t.Errorf("hidden: got %v, want true", pref["hidden"])
}
if pref["sort_order"] != float64(5) {
t.Errorf("sort_order: got %v, want 5", pref["sort_order"])
}
}
// ── Upsert — no duplicate rows ────────────
func TestModelPrefs_Upsert_NoDuplicate(t *testing.T) {
h := setupModelPrefsHarness(t)
for _, hidden := range []bool{true, false, true} {
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "test-model-1",
"provider_config_id": h.configID,
"hidden": hidden,
})
if resp.Code != http.StatusOK {
t.Fatalf("PUT hidden=%v: got %d", hidden, resp.Code)
}
}
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) != 1 {
t.Fatalf("upsert produced %d rows, want 1", len(arr))
}
pref := arr[0].(map[string]interface{})
if pref["hidden"] != true {
t.Errorf("final hidden state should be true, got %v", pref["hidden"])
}
}
// ── Validation: missing fields ────────────
func TestModelPrefs_Set_MissingModelID(t *testing.T) {
h := setupModelPrefsHarness(t)
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"provider_config_id": h.configID,
"hidden": true,
})
if resp.Code != http.StatusBadRequest {
t.Fatalf("missing model_id: want 400, got %d", resp.Code)
}
}
func TestModelPrefs_Set_MissingProviderConfigID(t *testing.T) {
h := setupModelPrefsHarness(t)
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "test-model-1",
"hidden": true,
})
if resp.Code != http.StatusBadRequest {
t.Fatalf("missing provider_config_id: want 400, got %d", resp.Code)
}
}
// ── Bulk set hidden ───────────────────────
func TestModelPrefs_BulkSetHidden(t *testing.T) {
h := setupModelPrefsHarness(t)
resp := h.request("POST", "/api/v1/models/preferences/bulk", h.userToken, map[string]interface{}{
"entries": []map[string]string{
{"model_id": "bulk-model-a", "provider_config_id": h.configID},
{"model_id": "bulk-model-b", "provider_config_id": h.configID},
},
"hidden": true,
})
if resp.Code != http.StatusOK {
t.Fatalf("bulk: got %d, body: %s", resp.Code, resp.Body.String())
}
var bulkResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&bulkResp)
if bulkResp["count"] != float64(2) {
t.Errorf("bulk count: got %v, want 2", bulkResp["count"])
}
// Verify both are hidden
resp = h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
hiddenCount := 0
for _, raw := range arr {
p := raw.(map[string]interface{})
if p["hidden"] == true {
hiddenCount++
}
}
if hiddenCount < 2 {
t.Errorf("expected at least 2 hidden prefs, got %d", hiddenCount)
}
}
// ── User isolation ────────────────────────
func TestModelPrefs_UserIsolation(t *testing.T) {
h := setupModelPrefsHarness(t)
// User 1 sets a preference
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "isolated-model",
"provider_config_id": h.configID,
"hidden": true,
})
if resp.Code != http.StatusOK {
t.Fatalf("user1 PUT: got %d", resp.Code)
}
// User 2 should see empty preferences
resp = h.request("GET", "/api/v1/models/preferences", h.user2Token, nil)
if resp.Code != http.StatusOK {
t.Fatalf("user2 GET: got %d", resp.Code)
}
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) != 0 {
t.Fatalf("user2 should have 0 prefs, got %d", len(arr))
}
}
// ── Shape validation ──────────────────────
func TestModelPrefs_ResponseShape(t *testing.T) {
h := setupModelPrefsHarness(t)
// Create a preference so we have something to inspect
h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "shape-model",
"provider_config_id": h.configID,
"hidden": false,
"sort_order": 0,
})
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) == 0 {
t.Fatal("expected at least 1 preference for shape check")
}
pref := arr[0].(map[string]interface{})
requiredFields := []string{"id", "user_id", "model_id", "provider_config_id", "hidden", "sort_order", "created_at", "updated_at"}
for _, f := range requiredFields {
if _, ok := pref[f]; !ok {
t.Errorf("missing required field %q in preference response", f)
}
}
// id, user_id, model_id, provider_config_id should be non-empty strings
for _, f := range []string{"id", "user_id", "model_id", "provider_config_id"} {
v, _ := pref[f].(string)
if v == "" {
t.Errorf("field %q should be a non-empty string, got %v", f, pref[f])
}
}
}
// ── Different provider_config_id = different entry ──
func TestModelPrefs_SameModel_DifferentProvider(t *testing.T) {
h := setupModelPrefsHarness(t)
// Seed a second provider config
config2ID := uuid.New().String()
database.TestDB.Exec(dialectSQL(
"INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope) VALUES ($1, 'global', 'Test Provider 2', 'anthropic', 'https://api.anthropic.com/v1', 'global')"),
config2ID)
// Set preference for same model under two providers
h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "dual-provider-model",
"provider_config_id": h.configID,
"hidden": true,
})
h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "dual-provider-model",
"provider_config_id": config2ID,
"hidden": false,
})
// Should have two distinct entries
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
matches := 0
for _, raw := range arr {
p := raw.(map[string]interface{})
if p["model_id"] == "dual-provider-model" {
matches++
}
}
if matches != 2 {
t.Fatalf("same model, different providers: expected 2 entries, got %d", matches)
}
}

View File

@@ -12,6 +12,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/middleware" "git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
@@ -61,6 +62,16 @@ func setupNotifHarness(t *testing.T) *notifHarness {
protected.PUT("/notifications/preferences/:type", notifH.SetPreference) protected.PUT("/notifications/preferences/:type", notifH.SetPreference)
protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference) protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference)
// Admin broadcast route (v0.28.6)
admin := api.Group("/admin")
admin.Use(middleware.Auth(cfg, stores.Users, userCache))
admin.Use(middleware.RequireAdmin())
admin.POST("/notifications/broadcast", notifH.Broadcast)
// Set up notification service singleton for Broadcast handler
notifSvc := notifications.NewService(stores.Notifications, nil)
notifications.SetDefault(notifSvc)
// Seed admin // Seed admin
adminID := database.SeedTestUser(t, "notif-admin", "notif-admin@test.com") adminID := database.SeedTestUser(t, "notif-admin", "notif-admin@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), adminID) database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), adminID)
@@ -548,6 +559,110 @@ func TestNotifications_Unauthenticated(t *testing.T) {
} }
} }
// ── Admin Broadcast (v0.28.6) ────────────
func TestBroadcast_Success(t *testing.T) {
h := setupNotifHarness(t)
resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{
"title": "Maintenance Tonight",
"message": "Servers will be down from 2-4 AM.",
"level": "warning",
})
if resp.Code != http.StatusOK {
t.Fatalf("broadcast: got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
if body["message"] != "broadcast sent" {
t.Errorf("expected 'broadcast sent', got %v", body["message"])
}
count := body["count"].(float64)
if count < 2 {
t.Errorf("expected at least 2 recipients (admin+user), got %v", count)
}
// Verify notification created for regular user
resp = h.request("GET", "/api/v1/notifications", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("list notifications: got %d", resp.Code)
}
var listBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&listBody)
items := listBody["data"].([]interface{})
if len(items) == 0 {
t.Fatal("user should have received the broadcast notification")
}
n := items[0].(map[string]interface{})
if n["type"] != "system.announcement" {
t.Errorf("notification type: got %v, want system.announcement", n["type"])
}
if n["title"] != "Maintenance Tonight" {
t.Errorf("notification title: got %v", n["title"])
}
}
func TestBroadcast_NonAdmin403(t *testing.T) {
h := setupNotifHarness(t)
resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.userToken, map[string]interface{}{
"title": "Unauthorized",
"message": "Should fail.",
})
if resp.Code != http.StatusForbidden {
t.Fatalf("non-admin broadcast: want 403, got %d", resp.Code)
}
}
func TestBroadcast_MissingTitle(t *testing.T) {
h := setupNotifHarness(t)
resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{
"message": "No title.",
})
if resp.Code != http.StatusBadRequest {
t.Fatalf("missing title: want 400, got %d", resp.Code)
}
}
func TestBroadcast_MissingMessage(t *testing.T) {
h := setupNotifHarness(t)
resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{
"title": "No message.",
})
if resp.Code != http.StatusBadRequest {
t.Fatalf("missing message: want 400, got %d", resp.Code)
}
}
func TestBroadcast_InvalidLevel(t *testing.T) {
h := setupNotifHarness(t)
resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{
"title": "Bad level",
"message": "Test",
"level": "extreme",
})
if resp.Code != http.StatusBadRequest {
t.Fatalf("invalid level: want 400, got %d", resp.Code)
}
}
func TestBroadcast_DefaultLevel(t *testing.T) {
h := setupNotifHarness(t)
// Omit level — should default to info and succeed
resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{
"title": "Info broadcast",
"message": "Default level test.",
})
if resp.Code != http.StatusOK {
t.Fatalf("default level: want 200, got %d, body: %s", resp.Code, resp.Body.String())
}
}
// ── helpers ──────────────────────────────── // ── helpers ────────────────────────────────
func keys(m map[string]json.RawMessage) []string { func keys(m map[string]json.RawMessage) []string {

View File

@@ -3,6 +3,7 @@ package handlers
import ( import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@@ -11,6 +12,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/events" "git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/store"
) )
@@ -279,3 +281,74 @@ func (h *NotificationHandler) DeletePreference(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true}) c.JSON(http.StatusOK, gin.H{"ok": true})
} }
// ── Admin Broadcast (v0.28.6) ───────────────
// POST /api/v1/admin/notifications/broadcast
// Admin-only: sends a system.announcement notification to all active users.
func (h *NotificationHandler) Broadcast(c *gin.Context) {
var req struct {
Title string `json:"title" binding:"required"`
Message string `json:"message" binding:"required"`
Level string `json:"level"` // info | warning | critical (default: info)
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
level := req.Level
if level == "" {
level = "info"
}
if level != "info" && level != "warning" && level != "critical" {
c.JSON(http.StatusBadRequest, gin.H{"error": "level must be info, warning, or critical"})
return
}
// Get all active user IDs
userIDs, err := h.stores.Users.ListActiveUserIDs(c.Request.Context())
if err != nil {
log.Printf("[broadcast] failed to list active users: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"})
return
}
if len(userIDs) == 0 {
c.JSON(http.StatusOK, gin.H{"message": "no active users", "count": 0})
return
}
// Fan out via notification service
svc := notifications.Default()
if svc == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "notification service not available"})
return
}
template := models.Notification{
Type: models.NotifTypeAnnouncement,
Title: req.Title,
Body: req.Message,
}
svc.NotifyMany(c.Request.Context(), userIDs, template)
// Also emit a real-time WS broadcast for immediate visibility
if h.hub != nil {
payload, _ := json.Marshal(map[string]string{
"title": req.Title,
"message": req.Message,
"level": level,
})
for _, uid := range userIDs {
h.hub.SendToUser(uid, events.Event{
Label: "system.broadcast",
Payload: payload,
Ts: time.Now().UnixMilli(),
})
}
}
log.Printf("[broadcast] admin sent announcement to %d users: %s", len(userIDs), req.Title)
c.JSON(http.StatusOK, gin.H{"message": "broadcast sent", "count": len(userIDs)})
}

View File

@@ -141,6 +141,22 @@ func (h *TaskHandler) Create(c *gin.Context) {
return return
} }
// v0.28.6: System tasks — admin-only, requires valid system_function
if t.TaskType == "system" {
if c.GetString("role") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "system tasks are admin-only"})
return
}
if t.SystemFunction == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "system_function is required for system tasks"})
return
}
if err := taskutil.ValidateSystemFunc(t.SystemFunction); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
// Validate required fields // Validate required fields
if t.Name == "" { if t.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
@@ -540,3 +556,9 @@ func (h *TriggerHandler) Handle(c *gin.Context) {
"task_id": task.ID, "task_id": task.ID,
}) })
} }
// ListSystemFunctions returns the available system function names and descriptions.
// GET /admin/system-functions
func (h *TaskHandler) ListSystemFunctions(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": taskutil.ListSystemFuncs()})
}

View File

@@ -408,6 +408,9 @@ func main() {
// v0.27.2: Task scheduler with executor — needs hub + notification service // v0.27.2: Task scheduler with executor — needs hub + notification service
if stores.Tasks != nil { if stores.Tasks != nil {
// v0.28.6: Register built-in system task functions
scheduler.RegisterBuiltins()
exec := scheduler.NewExecutor(stores, keyResolver, hub, healthAccum) exec := scheduler.NewExecutor(stores, keyResolver, hub, healthAccum)
taskSched := scheduler.New(stores, exec) taskSched := scheduler.New(stores, exec)
go taskSched.Run() go taskSched.Run()
@@ -813,7 +816,9 @@ func main() {
// Git credentials (v0.21.4) — user-scoped, independent of workspace // Git credentials (v0.21.4) — user-scoped, independent of workspace
gitCredH := handlers.NewGitCredentialHandler(stores, keyResolver) gitCredH := handlers.NewGitCredentialHandler(stores, keyResolver)
protected.POST("/git-credentials", gitCredH.Create) protected.POST("/git-credentials", gitCredH.Create)
protected.POST("/git-credentials/generate", gitCredH.Generate)
protected.GET("/git-credentials", gitCredH.List) protected.GET("/git-credentials", gitCredH.List)
protected.GET("/git-credentials/:id/public-key", gitCredH.GetPublicKey)
protected.DELETE("/git-credentials/:id", gitCredH.Delete) protected.DELETE("/git-credentials/:id", gitCredH.Delete)
// Files (upload/download) // Files (upload/download)
@@ -1017,6 +1022,10 @@ func main() {
admin.PUT("/teams/:id/members/:memberId", teamAdm.UpdateMember) admin.PUT("/teams/:id/members/:memberId", teamAdm.UpdateMember)
admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember) admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember)
// Admin broadcast (v0.28.6)
adminNotifH := handlers.NewNotificationHandler(stores, hub)
admin.POST("/notifications/broadcast", adminNotifH.Broadcast)
// Audit log // Audit log
admin.GET("/audit", adm.ListAuditLog) admin.GET("/audit", adm.ListAuditLog)
admin.GET("/audit/actions", adm.ListAuditActions) admin.GET("/audit/actions", adm.ListAuditActions)
@@ -1134,6 +1143,7 @@ func main() {
admin.POST("/tasks/:id/run", taskAdm.RunNow) admin.POST("/tasks/:id/run", taskAdm.RunNow)
admin.POST("/tasks/:id/kill", taskAdm.KillRun) admin.POST("/tasks/:id/kill", taskAdm.KillRun)
admin.DELETE("/tasks/:id", taskAdm.Delete) admin.DELETE("/tasks/:id", taskAdm.Delete)
admin.GET("/system-functions", taskAdm.ListSystemFunctions)
} }
} }

View File

@@ -13,24 +13,33 @@ type GitCredential struct {
AuthType string `json:"auth_type" db:"auth_type"` // https_pat, https_basic, ssh_key AuthType string `json:"auth_type" db:"auth_type"` // https_pat, https_basic, ssh_key
EncryptedData []byte `json:"-" db:"encrypted_data"` // never expose EncryptedData []byte `json:"-" db:"encrypted_data"` // never expose
Nonce []byte `json:"-" db:"nonce"` // never expose Nonce []byte `json:"-" db:"nonce"` // never expose
PublicKey string `json:"public_key,omitempty" db:"public_key"`
Fingerprint string `json:"fingerprint,omitempty" db:"fingerprint"`
PersonaID *string `json:"persona_id,omitempty" db:"persona_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
} }
// GitCredentialSummary is the safe-to-expose version (no encrypted data). // GitCredentialSummary is the safe-to-expose version (no encrypted data).
type GitCredentialSummary struct { type GitCredentialSummary struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
AuthType string `json:"auth_type"` AuthType string `json:"auth_type"`
CreatedAt time.Time `json:"created_at"` PublicKey string `json:"public_key,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
PersonaID *string `json:"persona_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
} }
// Summary returns a safe version without encrypted fields. // Summary returns a safe version without encrypted fields.
func (c *GitCredential) Summary() GitCredentialSummary { func (c *GitCredential) Summary() GitCredentialSummary {
return GitCredentialSummary{ return GitCredentialSummary{
ID: c.ID, ID: c.ID,
Name: c.Name, Name: c.Name,
AuthType: c.AuthType, AuthType: c.AuthType,
CreatedAt: c.CreatedAt, PublicKey: c.PublicKey,
Fingerprint: c.Fingerprint,
PersonaID: c.PersonaID,
CreatedAt: c.CreatedAt,
} }
} }

View File

@@ -34,6 +34,7 @@ const (
NotifTypeTaskCompleted = "task.completed" NotifTypeTaskCompleted = "task.completed"
NotifTypeTaskFailed = "task.failed" NotifTypeTaskFailed = "task.failed"
NotifTypeTaskBudget = "task.budget_exceeded" NotifTypeTaskBudget = "task.budget_exceeded"
NotifTypeAnnouncement = "system.announcement"
) )
// Resource type constants for click-to-navigate. // Resource type constants for click-to-navigate.

View File

@@ -7,7 +7,8 @@ import (
// Task is a scheduled, one-shot, or webhook-triggered job that creates a // Task is a scheduled, one-shot, or webhook-triggered job that creates a
// service channel and runs a completion (prompt task), instantiates a // service channel and runs a completion (prompt task), instantiates a
// workflow, or relays a payload without LLM involvement (action task). // workflow, relays a payload without LLM involvement (action task), or
// executes a built-in Go function (system task).
type Task struct { type Task struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
OwnerID string `json:"owner_id" db:"owner_id"` OwnerID string `json:"owner_id" db:"owner_id"`
@@ -17,7 +18,8 @@ type Task struct {
Scope string `json:"scope" db:"scope"` // personal | team | global Scope string `json:"scope" db:"scope"` // personal | team | global
// What to run // What to run
TaskType string `json:"task_type" db:"task_type"` // prompt | workflow | action TaskType string `json:"task_type" db:"task_type"` // prompt | workflow | action | system
SystemFunction string `json:"system_function,omitempty" db:"system_function"`
PersonaID *string `json:"persona_id,omitempty" db:"persona_id"` PersonaID *string `json:"persona_id,omitempty" db:"persona_id"`
ModelID string `json:"model_id" db:"model_id"` ModelID string `json:"model_id" db:"model_id"`
SystemPrompt string `json:"system_prompt" db:"system_prompt"` SystemPrompt string `json:"system_prompt" db:"system_prompt"`

View File

@@ -103,6 +103,7 @@
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/marked.min.js"></script> <script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/marked.min.js"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/purify.min.js"></script> <script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/purify.min.js"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-format.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-format.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/virtual-scroll.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-core.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-core.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/pages.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/pages.js?v={{.Version}}"></script>
{{/* v0.25.0: Component scripts — available on all surfaces */}} {{/* v0.25.0: Component scripts — available on all surfaces */}}

View File

@@ -36,7 +36,7 @@
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
Routing Routing
</a> </a>
<a href="{{$base}}/admin/settings" class="admin-cat-btn{{if eq $section "settings"}} active{{else if eq $section "storage"}} active{{else if eq $section "extensions"}} active{{else if eq $section "channels"}} active{{else if eq $section "surfaces"}} active{{end}}" data-cat="system"> <a href="{{$base}}/admin/settings" class="admin-cat-btn{{if eq $section "settings"}} active{{else if eq $section "storage"}} active{{else if eq $section "extensions"}} active{{else if eq $section "channels"}} active{{else if eq $section "surfaces"}} active{{else if eq $section "broadcast"}} active{{end}}" data-cat="system">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09"/></svg>
System System
</a> </a>
@@ -220,6 +220,7 @@
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-admin.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-admin.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/task-admin.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/task-admin.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-surfaces.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-surfaces.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/broadcast-admin.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}"> <script nonce="{{.CSPNonce}}">
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// ── Admin navigation: don't pollute browser history ── // ── Admin navigation: don't pollute browser history ──

View File

@@ -32,6 +32,7 @@
<a href="{{$base}}/settings/teams" class="settings-nav-link{{if eq $section "teams"}} active{{end}}">Teams</a> <a href="{{$base}}/settings/teams" class="settings-nav-link{{if eq $section "teams"}} active{{end}}">Teams</a>
<a href="{{$base}}/settings/workflows" class="settings-nav-link{{if eq $section "workflows"}} active{{end}}">Workflows</a> <a href="{{$base}}/settings/workflows" class="settings-nav-link{{if eq $section "workflows"}} active{{end}}">Workflows</a>
<a href="{{$base}}/settings/tasks" class="settings-nav-link{{if eq $section "tasks"}} active{{end}}">Tasks</a> <a href="{{$base}}/settings/tasks" class="settings-nav-link{{if eq $section "tasks"}} active{{end}}">Tasks</a>
<a href="{{$base}}/settings/gitkeys" class="settings-nav-link{{if eq $section "gitkeys"}} active{{end}}">Git Keys</a>
{{/* BYOK-gated nav items */}} {{/* BYOK-gated nav items */}}
<div id="settingsByokNav" style="display:none;"> <div id="settingsByokNav" style="display:none;">
@@ -173,6 +174,7 @@
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-settings.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-settings.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/settings-handlers.js?v={{.Version}}"></script> <script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/settings-handlers.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/task-settings.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/task-settings.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/git-credentials-ui.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/persona-kb.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/persona-kb.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/memory-ui.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/memory-ui.js?v={{.Version}}"></script>
@@ -256,6 +258,7 @@
teams: () => typeof UI !== 'undefined' && UI.loadTeamsSettings?.(), teams: () => typeof UI !== 'undefined' && UI.loadTeamsSettings?.(),
workflows: () => typeof loadTeamWorkflows === 'function' && loadTeamWorkflows(), workflows: () => typeof loadTeamWorkflows === 'function' && loadTeamWorkflows(),
tasks: () => typeof _loadSettingsTasks === 'function' && _loadSettingsTasks(), tasks: () => typeof _loadSettingsTasks === 'function' && _loadSettingsTasks(),
gitkeys: () => typeof _loadSettingsGitKeys === 'function' && _loadSettingsGitKeys(),
}; };
// Populate App.policies and App.models from API. // Populate App.policies and App.models from API.

View File

@@ -51,6 +51,12 @@ func NewExecutor(stores store.Stores, vault *crypto.KeyResolver, hub *events.Hub
func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.TaskRun, channelID string) { func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.TaskRun, channelID string) {
startTime := time.Now() startTime := time.Now()
// v0.28.6: System tasks run a built-in Go function. No LLM, no provider, no channel.
if task.TaskType == "system" {
e.executeSystem(ctx, task, run, startTime)
return
}
// v0.28.0: Action tasks skip the LLM pipeline entirely. // v0.28.0: Action tasks skip the LLM pipeline entirely.
if task.TaskType == "action" { if task.TaskType == "action" {
e.executeAction(ctx, task, run, channelID, startTime) e.executeAction(ctx, task, run, channelID, startTime)
@@ -259,6 +265,56 @@ func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.Ta
} }
} }
// executeSystem runs a built-in Go function from the system registry.
// No LLM, no provider resolution, no channel needed.
func (e *Executor) executeSystem(ctx context.Context, task models.Task, run *models.TaskRun, startTime time.Time) {
fn, ok := taskutil.GetSystemFunc(task.SystemFunction)
if !ok {
e.failRun(ctx, task, run, "unknown system function: "+task.SystemFunction)
return
}
result, err := fn(ctx, e.stores)
wallClock := int(time.Since(startTime).Seconds())
status := "completed"
errMsg := ""
if err != nil {
status = "failed"
errMsg = err.Error()
}
// Store result as the run's output (tokens=0, tools=0 for system tasks)
_ = e.stores.Tasks.UpdateRun(ctx, run.ID, status, 0, 0, wallClock, errMsg)
_ = e.stores.Tasks.IncrementRunCount(ctx, task.ID)
log.Printf("[executor] System task %s (%s → %s) → %s (wall=%ds, result=%s)",
task.ID, task.Name, task.SystemFunction, status, wallClock, truncate(result, 200))
e.notifyOwner(ctx, task, status, errMsg)
// Outbound webhook with result
if task.WebhookURL != "" {
go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{
TaskID: task.ID,
RunID: run.ID,
TaskName: task.Name,
Status: status,
CompletedAt: time.Now().UTC(),
Output: result,
Error: errMsg,
})
}
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// executeAction handles non-LLM action tasks. // executeAction handles non-LLM action tasks.
// Skips provider resolution and completion entirely. // Skips provider resolution and completion entirely.
func (e *Executor) executeAction(ctx context.Context, task models.Task, run *models.TaskRun, channelID string, startTime time.Time) { func (e *Executor) executeAction(ctx context.Context, task models.Task, run *models.TaskRun, channelID string, startTime time.Time) {

View File

@@ -0,0 +1,130 @@
// Package scheduler — system_builtins.go
//
// v0.28.6: Built-in system functions registered at startup.
// These mirror the background goroutines in main.go but run as visible,
// scheduled, auditable tasks. The goroutines remain as fallback until
// system tasks are validated in production.
package scheduler
import (
"context"
"fmt"
"log"
"time"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
)
// RegisterBuiltins registers all built-in system functions.
// Called once from main.go at startup.
func RegisterBuiltins() {
taskutil.RegisterSystemFunc("session_cleanup",
"Remove expired anonymous visitor sessions",
sessionCleanup)
taskutil.RegisterSystemFunc("staleness_check",
"Mark idle workflow instances as stale",
stalenessCheck)
taskutil.RegisterSystemFunc("retention_sweep",
"Delete completed workflow instances past retention policy",
retentionSweep)
taskutil.RegisterSystemFunc("health_prune",
"Prune provider health windows older than 7 days",
healthPrune)
}
// ── session_cleanup ─────────────────────────
func sessionCleanup(ctx context.Context, stores store.Stores) (string, error) {
if stores.Sessions == nil {
return "skipped: session store not available", nil
}
// Default: expire sessions older than 7 days
cutoff := time.Now().UTC().AddDate(0, 0, -7)
n, err := stores.Sessions.DeleteExpired(ctx, cutoff)
if err != nil {
return "", fmt.Errorf("session cleanup failed: %w", err)
}
msg := fmt.Sprintf("cleaned up %d expired sessions (cutoff: %s)", n, cutoff.Format(time.RFC3339))
if n > 0 {
log.Printf("[system_task] %s", msg)
}
return msg, nil
}
// ── staleness_check ─────────────────────────
func stalenessCheck(ctx context.Context, stores store.Stores) (string, error) {
// Default: 48 hours idle → stale
cutoff := time.Now().UTC().Add(-48 * time.Hour)
res, err := database.DB.ExecContext(ctx, database.Q(`
UPDATE channels
SET workflow_status = 'stale'
WHERE type = 'workflow'
AND workflow_status = 'active'
AND last_activity_at < $1
`), cutoff)
if err != nil {
return "", fmt.Errorf("staleness sweep failed: %w", err)
}
n, _ := res.RowsAffected()
msg := fmt.Sprintf("marked %d idle workflow instances as stale (cutoff: %s)", n, cutoff.Format(time.RFC3339))
if n > 0 {
log.Printf("[system_task] %s", msg)
}
return msg, nil
}
// ── retention_sweep ─────────────────────────
func retentionSweep(ctx context.Context, stores store.Stores) (string, error) {
if database.IsSQLite() {
return "skipped: retention enforcement requires PostgreSQL (JSON operators)", nil
}
cutoff := time.Now().UTC().Add(-48 * time.Hour)
res, err := database.DB.ExecContext(ctx, database.Q(`
DELETE FROM channels
WHERE type = 'workflow'
AND workflow_status IN ('completed', 'archived')
AND workflow_id IS NOT NULL
AND last_activity_at < $1
AND workflow_id IN (
SELECT id FROM workflows
WHERE retention IS NOT NULL
AND retention->>'mode' = 'delete'
AND (retention->>'delete_after_days')::int > 0
AND channels.last_activity_at < now() - ((retention->>'delete_after_days')::int || ' days')::interval
)
`), cutoff)
if err != nil {
return "", fmt.Errorf("retention enforcement failed: %w", err)
}
n, _ := res.RowsAffected()
msg := fmt.Sprintf("deleted %d expired workflow instances (retention policy)", n)
if n > 0 {
log.Printf("[system_task] %s", msg)
}
return msg, nil
}
// ── health_prune ────────────────────────────
func healthPrune(ctx context.Context, stores store.Stores) (string, error) {
cutoff := time.Now().UTC().Add(-7 * 24 * time.Hour)
res, err := database.DB.ExecContext(ctx, database.Q(`
DELETE FROM health_windows WHERE window_start < $1
`), cutoff)
if err != nil {
return "", fmt.Errorf("health prune failed: %w", err)
}
n, _ := res.RowsAffected()
msg := fmt.Sprintf("pruned %d old health windows (cutoff: %s)", n, cutoff.Format(time.RFC3339))
if n > 0 {
log.Printf("[system_task] %s", msg)
}
return msg, nil
}

View File

@@ -183,6 +183,7 @@ type UserStore interface {
List(ctx context.Context, opts ListOptions) ([]models.User, int, error) List(ctx context.Context, opts ListOptions) ([]models.User, int, error)
UpdateLastLogin(ctx context.Context, id string) error UpdateLastLogin(ctx context.Context, id string) error
SetActive(ctx context.Context, id string, active bool) error SetActive(ctx context.Context, id string, active bool) error
ListActiveUserIDs(ctx context.Context) ([]string, error)
// Refresh tokens // Refresh tokens
CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error

View File

@@ -13,29 +13,32 @@ type GitCredentialStore struct{}
func (s *GitCredentialStore) Create(ctx context.Context, cred *models.GitCredential) error { func (s *GitCredentialStore) Create(ctx context.Context, cred *models.GitCredential) error {
if cred.ID != "" { if cred.ID != "" {
return DB.QueryRowContext(ctx, ` return DB.QueryRowContext(ctx, `
INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce) INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING created_at`, RETURNING created_at`,
cred.ID, cred.UserID, cred.Name, cred.AuthType, cred.ID, cred.UserID, cred.Name, cred.AuthType,
cred.EncryptedData, cred.Nonce, cred.EncryptedData, cred.Nonce,
cred.PublicKey, cred.Fingerprint, cred.PersonaID,
).Scan(&cred.CreatedAt) ).Scan(&cred.CreatedAt)
} }
return DB.QueryRowContext(ctx, ` return DB.QueryRowContext(ctx, `
INSERT INTO git_credentials (user_id, name, auth_type, encrypted_data, nonce) INSERT INTO git_credentials (user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, created_at`, RETURNING id, created_at`,
cred.UserID, cred.Name, cred.AuthType, cred.UserID, cred.Name, cred.AuthType,
cred.EncryptedData, cred.Nonce, cred.EncryptedData, cred.Nonce,
cred.PublicKey, cred.Fingerprint, cred.PersonaID,
).Scan(&cred.ID, &cred.CreatedAt) ).Scan(&cred.ID, &cred.CreatedAt)
} }
func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.GitCredential, error) { func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.GitCredential, error) {
var c models.GitCredential var c models.GitCredential
err := DB.QueryRowContext(ctx, ` err := DB.QueryRowContext(ctx, `
SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at
FROM git_credentials WHERE id = $1`, id, FROM git_credentials WHERE id = $1`, id,
).Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType, ).Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
&c.EncryptedData, &c.Nonce, &c.CreatedAt) &c.EncryptedData, &c.Nonce,
&c.PublicKey, &c.Fingerprint, &c.PersonaID, &c.CreatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -44,7 +47,7 @@ func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.Gi
func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error) { func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error) {
rows, err := DB.QueryContext(ctx, ` rows, err := DB.QueryContext(ctx, `
SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at
FROM git_credentials WHERE user_id = $1 ORDER BY created_at DESC`, userID) FROM git_credentials WHERE user_id = $1 ORDER BY created_at DESC`, userID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -55,7 +58,8 @@ func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]m
for rows.Next() { for rows.Next() {
var c models.GitCredential var c models.GitCredential
if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType, if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
&c.EncryptedData, &c.Nonce, &c.CreatedAt); err != nil { &c.EncryptedData, &c.Nonce,
&c.PublicKey, &c.Fingerprint, &c.PersonaID, &c.CreatedAt); err != nil {
return nil, err return nil, err
} }
creds = append(creds, c) creds = append(creds, c)

View File

@@ -23,7 +23,8 @@ func NewTaskStore() *TaskStore { return &TaskStore{} }
// (string, json.RawMessage). database/sql returns "unsupported Scan, // (string, json.RawMessage). database/sql returns "unsupported Scan,
// storing driver.Value type <nil>" for these without COALESCE. // storing driver.Value type <nil>" for these without COALESCE.
const taskColumns = `id, owner_id, team_id, name, description, scope, const taskColumns = `id, owner_id, team_id, name, description, scope,
task_type, persona_id, model_id, system_prompt, user_prompt, task_type, COALESCE(system_function, '') AS system_function,
persona_id, model_id, system_prompt, user_prompt,
workflow_id, COALESCE(tool_grants, '[]'::jsonb) AS tool_grants, workflow_id, COALESCE(tool_grants, '[]'::jsonb) AS tool_grants,
schedule, timezone, is_active, schedule, timezone, is_active,
COALESCE(trigger_token, '') AS trigger_token, COALESCE(trigger_token, '') AS trigger_token,
@@ -40,7 +41,8 @@ const taskColumns = `id, owner_id, team_id, name, description, scope,
func scanTask(scanner interface{ Scan(...interface{}) error }, t *models.Task) error { func scanTask(scanner interface{ Scan(...interface{}) error }, t *models.Task) error {
return scanner.Scan( return scanner.Scan(
&t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope, &t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope,
&t.TaskType, &t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt, &t.TaskType, &t.SystemFunction,
&t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt,
&t.WorkflowID, &t.ToolGrants, &t.Schedule, &t.Timezone, &t.IsActive, &t.WorkflowID, &t.ToolGrants, &t.Schedule, &t.Timezone, &t.IsActive,
&t.TriggerToken, &t.TriggerToken,
&t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode, &t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode,
@@ -56,16 +58,18 @@ func (s *TaskStore) Create(ctx context.Context, t *models.Task) error {
toolGrants := jsonOrNull(t.ToolGrants) toolGrants := jsonOrNull(t.ToolGrants)
return DB.QueryRowContext(ctx, ` return DB.QueryRowContext(ctx, `
INSERT INTO tasks (owner_id, team_id, name, description, scope, INSERT INTO tasks (owner_id, team_id, name, description, scope,
task_type, persona_id, model_id, system_prompt, user_prompt, task_type, system_function,
persona_id, model_id, system_prompt, user_prompt,
workflow_id, tool_grants, schedule, timezone, is_active, workflow_id, tool_grants, schedule, timezone, is_active,
trigger_token, trigger_token,
max_tokens, max_tool_calls, max_wall_clock, output_mode, max_tokens, max_tool_calls, max_wall_clock, output_mode,
output_channel_id, webhook_url, webhook_secret, provider_config_id, output_channel_id, webhook_url, webhook_secret, provider_config_id,
notify_on_complete, notify_on_failure, next_run_at) notify_on_complete, notify_on_failure, next_run_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28)
RETURNING id, created_at, updated_at`, RETURNING id, created_at, updated_at`,
t.OwnerID, t.TeamID, t.Name, t.Description, t.Scope, t.OwnerID, t.TeamID, t.Name, t.Description, t.Scope,
t.TaskType, t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt, t.TaskType, t.SystemFunction,
t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt,
t.WorkflowID, toolGrants, t.Schedule, t.Timezone, t.IsActive, t.WorkflowID, toolGrants, t.Schedule, t.Timezone, t.IsActive,
nilIfEmpty(t.TriggerToken), nilIfEmpty(t.TriggerToken),
t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode, t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode,

View File

@@ -122,6 +122,23 @@ func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error
return err return err
} }
func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
rows, err := DB.QueryContext(ctx, "SELECT id FROM users WHERE is_active = true")
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ── Refresh Tokens ────────────────────────── // ── Refresh Tokens ──────────────────────────
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error { func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {

View File

@@ -18,11 +18,12 @@ func (s *GitCredentialStore) Create(ctx context.Context, cred *models.GitCredent
cred.ID = uuid.NewString() cred.ID = uuid.NewString()
} }
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce, created_at) INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
cred.ID, cred.UserID, cred.Name, cred.AuthType, cred.ID, cred.UserID, cred.Name, cred.AuthType,
base64.StdEncoding.EncodeToString(cred.EncryptedData), base64.StdEncoding.EncodeToString(cred.EncryptedData),
base64.StdEncoding.EncodeToString(cred.Nonce), base64.StdEncoding.EncodeToString(cred.Nonce),
cred.PublicKey, cred.Fingerprint, cred.PersonaID,
) )
if err != nil { if err != nil {
return err return err
@@ -37,10 +38,10 @@ func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.Gi
var c models.GitCredential var c models.GitCredential
var encData, nonce string var encData, nonce string
err := DB.QueryRowContext(ctx, ` err := DB.QueryRowContext(ctx, `
SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at
FROM git_credentials WHERE id = ?`, id, FROM git_credentials WHERE id = ?`, id,
).Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType, ).Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
&encData, &nonce, st(&c.CreatedAt)) &encData, &nonce, &c.PublicKey, &c.Fingerprint, &c.PersonaID, st(&c.CreatedAt))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -51,7 +52,7 @@ func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.Gi
func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error) { func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error) {
rows, err := DB.QueryContext(ctx, ` rows, err := DB.QueryContext(ctx, `
SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at
FROM git_credentials WHERE user_id = ? ORDER BY created_at DESC`, userID) FROM git_credentials WHERE user_id = ? ORDER BY created_at DESC`, userID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -63,7 +64,7 @@ func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]m
var c models.GitCredential var c models.GitCredential
var encData, nonce string var encData, nonce string
if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType, if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
&encData, &nonce, st(&c.CreatedAt)); err != nil { &encData, &nonce, &c.PublicKey, &c.Fingerprint, &c.PersonaID, st(&c.CreatedAt)); err != nil {
return nil, err return nil, err
} }
c.EncryptedData, _ = base64.StdEncoding.DecodeString(encData) c.EncryptedData, _ = base64.StdEncoding.DecodeString(encData)

View File

@@ -19,7 +19,8 @@ func NewTaskStore() *TaskStore { return &TaskStore{} }
// COALESCE wraps nullable columns that scan into non-pointer Go types. // COALESCE wraps nullable columns that scan into non-pointer Go types.
// tool_grants uses COALESCE to '[]' so the string intermediate never sees NULL. // tool_grants uses COALESCE to '[]' so the string intermediate never sees NULL.
const taskColumns = `id, owner_id, team_id, name, description, scope, const taskColumns = `id, owner_id, team_id, name, description, scope,
task_type, persona_id, model_id, system_prompt, user_prompt, task_type, COALESCE(system_function, '') AS system_function,
persona_id, model_id, system_prompt, user_prompt,
workflow_id, COALESCE(tool_grants, '[]') AS tool_grants, workflow_id, COALESCE(tool_grants, '[]') AS tool_grants,
schedule, timezone, is_active, schedule, timezone, is_active,
COALESCE(trigger_token, '') AS trigger_token, COALESCE(trigger_token, '') AS trigger_token,
@@ -47,7 +48,8 @@ func scanTask(scanner interface{ Scan(...interface{}) error }, t *models.Task) e
var isActive, notifyComplete, notifyFailure int var isActive, notifyComplete, notifyFailure int
err := scanner.Scan( err := scanner.Scan(
&t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope, &t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope,
&t.TaskType, &t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt, &t.TaskType, &t.SystemFunction,
&t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt,
&t.WorkflowID, &toolGrantsStr, &t.Schedule, &t.Timezone, &isActive, &t.WorkflowID, &toolGrantsStr, &t.Schedule, &t.Timezone, &isActive,
&t.TriggerToken, &t.TriggerToken,
&t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode, &t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode,
@@ -74,15 +76,17 @@ func (s *TaskStore) Create(ctx context.Context, t *models.Task) error {
t.UpdatedAt = now t.UpdatedAt = now
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
INSERT INTO tasks (id, owner_id, team_id, name, description, scope, INSERT INTO tasks (id, owner_id, team_id, name, description, scope,
task_type, persona_id, model_id, system_prompt, user_prompt, task_type, system_function,
persona_id, model_id, system_prompt, user_prompt,
workflow_id, tool_grants, schedule, timezone, is_active, workflow_id, tool_grants, schedule, timezone, is_active,
trigger_token, trigger_token,
max_tokens, max_tool_calls, max_wall_clock, output_mode, max_tokens, max_tool_calls, max_wall_clock, output_mode,
output_channel_id, webhook_url, webhook_secret, provider_config_id, output_channel_id, webhook_url, webhook_secret, provider_config_id,
notify_on_complete, notify_on_failure, next_run_at) notify_on_complete, notify_on_failure, next_run_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
t.ID, t.OwnerID, t.TeamID, t.Name, t.Description, t.Scope, t.ID, t.OwnerID, t.TeamID, t.Name, t.Description, t.Scope,
t.TaskType, t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt, t.TaskType, t.SystemFunction,
t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt,
t.WorkflowID, nullableJSON(t.ToolGrants), t.Schedule, t.Timezone, boolToInt(t.IsActive), t.WorkflowID, nullableJSON(t.ToolGrants), t.Schedule, t.Timezone, boolToInt(t.IsActive),
nilIfEmpty(t.TriggerToken), nilIfEmpty(t.TriggerToken),
t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode, t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode,

View File

@@ -128,6 +128,23 @@ func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error
return err return err
} }
func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
rows, err := DB.QueryContext(ctx, "SELECT id FROM users WHERE is_active = 1")
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ── Refresh Tokens ────────────────────────── // ── Refresh Tokens ──────────────────────────
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error { func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {

View File

@@ -0,0 +1,89 @@
// Package taskutil — system_registry.go
//
// v0.28.6: System task function registry.
//
// Built-in Go functions that run as auditable, scheduled tasks instead of
// ad-hoc goroutines. Each function receives a context, the full store set,
// and returns a structured result. The registry is permanent — system
// functions are not replaced by Starlark (v0.29.0). Core platform ops
// must not break from bad user code.
//
// Registration happens at init time from main.go. The executor calls
// Get() to look up a function by name at execution time.
package taskutil
import (
"context"
"fmt"
"sort"
"sync"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// SystemFunc is the signature for built-in system task functions.
// The function receives a context and the full store set. It returns
// a human-readable result string (logged in the task run) and an error.
type SystemFunc func(ctx context.Context, stores store.Stores) (string, error)
// SystemFuncInfo describes a registered system function.
type SystemFuncInfo struct {
Name string `json:"name"`
Description string `json:"description"`
}
var (
registryMu sync.RWMutex
registry = make(map[string]registryEntry)
)
type registryEntry struct {
fn SystemFunc
description string
}
// RegisterSystemFunc registers a named system function.
// Call during init (before scheduler starts). Not goroutine-safe for writes.
func RegisterSystemFunc(name, description string, fn SystemFunc) {
registryMu.Lock()
defer registryMu.Unlock()
registry[name] = registryEntry{fn: fn, description: description}
}
// GetSystemFunc returns a system function by name.
func GetSystemFunc(name string) (SystemFunc, bool) {
registryMu.RLock()
defer registryMu.RUnlock()
e, ok := registry[name]
if !ok {
return nil, false
}
return e.fn, true
}
// ListSystemFuncs returns all registered system function names and descriptions.
func ListSystemFuncs() []SystemFuncInfo {
registryMu.RLock()
defer registryMu.RUnlock()
result := make([]SystemFuncInfo, 0, len(registry))
for name, e := range registry {
result = append(result, SystemFuncInfo{Name: name, Description: e.description})
}
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
return result
}
// ValidateSystemFunc returns an error if the function name is not registered.
func ValidateSystemFunc(name string) error {
registryMu.RLock()
defer registryMu.RUnlock()
if _, ok := registry[name]; !ok {
names := make([]string, 0, len(registry))
for n := range registry {
names = append(names, n)
}
sort.Strings(names)
return fmt.Errorf("unknown system function %q (available: %v)", name, names)
}
return nil
}

96
src/js/broadcast-admin.js Normal file
View File

@@ -0,0 +1,96 @@
// ==========================================
// Chat Switchboard — Broadcast Admin Panel
// ==========================================
// Admin section for composing and sending system announcements.
// Registered as ADMIN_LOADERS.broadcast in ui-admin.js.
var SCAFFOLD_HTML =
'<div style="max-width:600px">' +
'<p style="color:var(--text-2);font-size:13px;line-height:1.6;margin-bottom:20px">' +
'Send a system announcement to all active users. Notifications appear in their ' +
'bell menu and optionally via email (based on user preferences).' +
'</p>' +
'<div class="form-group">' +
'<label>Title</label>' +
'<input type="text" id="broadcastTitle" class="input" placeholder="Scheduled maintenance tonight" autocomplete="off">' +
'</div>' +
'<div class="form-group">' +
'<label>Message</label>' +
'<textarea id="broadcastMessage" class="input" rows="4" placeholder="Details about the announcement…" style="resize:vertical;font-family:inherit"></textarea>' +
'</div>' +
'<div class="form-group">' +
'<label>Level</label>' +
'<select id="broadcastLevel" class="input">' +
'<option value="info">Info</option>' +
'<option value="warning">Warning</option>' +
'<option value="critical">Critical</option>' +
'</select>' +
'</div>' +
'<button id="broadcastSendBtn" class="btn-md btn-primary" style="margin-top:8px">' +
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>' +
' Send Broadcast' +
'</button>' +
'<div id="broadcastResult" style="margin-top:12px;display:none"></div>' +
'</div>';
sb.register('_loadAdminBroadcast', async function () {
var body = document.getElementById('adminDynamic') || document.getElementById('adminContentBody');
if (!body) return;
body.innerHTML = SCAFFOLD_HTML;
var sendBtn = document.getElementById('broadcastSendBtn');
var resultEl = document.getElementById('broadcastResult');
sendBtn.addEventListener('click', async function () {
var title = document.getElementById('broadcastTitle').value.trim();
var message = document.getElementById('broadcastMessage').value.trim();
var level = document.getElementById('broadcastLevel').value;
if (!title) { UI.toast('Title is required', 'warning'); return; }
if (!message) { UI.toast('Message is required', 'warning'); return; }
// Confirmation
var ok = await UI.confirm(
'Send broadcast to all active users?\n\n' +
'Level: ' + level + '\n' +
'Title: ' + title
);
if (!ok) return;
sendBtn.disabled = true;
sendBtn.textContent = 'Sending…';
resultEl.style.display = 'none';
try {
var resp = await API.post('/admin/notifications/broadcast', {
title: title,
message: message,
level: level,
});
var count = resp.count || 0;
resultEl.style.display = 'block';
resultEl.className = 'admin-success';
resultEl.innerHTML =
'<span style="color:var(--success);font-weight:600">✓</span> ' +
'Broadcast sent to ' + count + ' user' + (count !== 1 ? 's' : '') + '.';
// Clear form
document.getElementById('broadcastTitle').value = '';
document.getElementById('broadcastMessage').value = '';
document.getElementById('broadcastLevel').value = 'info';
UI.toast('Broadcast sent to ' + count + ' users', 'success');
} catch (err) {
resultEl.style.display = 'block';
resultEl.className = 'admin-error';
resultEl.innerHTML =
'<span style="color:var(--danger);font-weight:600">✗</span> ' +
'Failed: ' + (err.message || err);
UI.toast('Broadcast failed', 'error');
} finally {
sendBtn.disabled = false;
sendBtn.textContent = ' Send Broadcast';
}
});
});

View File

@@ -236,6 +236,8 @@ async function loadChats() {
async function selectChat(chatId) { async function selectChat(chatId) {
clearStaged(); // Discard any staged files from previous chat clearStaged(); // Discard any staged files from previous chat
// v0.28.6: Reset virtual scroll when switching conversations
if (typeof VirtualScroll !== 'undefined') VirtualScroll.destroy();
const chat = App.chats.find(c => c.id === chatId); const chat = App.chats.find(c => c.id === chatId);
App.setActive(chatId, chat?.type || 'direct'); App.setActive(chatId, chat?.type || 'direct');
try { sessionStorage.setItem('cs-active-conversation', JSON.stringify(App.activeConversation)); } catch (_) {} try { sessionStorage.setItem('cs-active-conversation', JSON.stringify(App.activeConversation)); } catch (_) {}
@@ -418,6 +420,7 @@ function _cleanStaleChatModel(chatId) {
async function newChat() { async function newChat() {
clearStaged(); // Discard any staged files clearStaged(); // Discard any staged files
if (typeof VirtualScroll !== 'undefined') VirtualScroll.destroy();
App.setActive(null); App.setActive(null);
UI.renderChatList(); UI.renderChatList();
UI.renderChannelsSection(); UI.renderChannelsSection();

View File

@@ -0,0 +1,180 @@
// ==========================================
// Chat Switchboard — Git Credentials Settings
// ==========================================
// Settings section for managing SSH keys used with git workspaces.
// Keys are generated server-side — private keys never leave the server.
// Users copy the public key into their repo host (GitHub, Gitea, etc.).
// Optional persona binding enables per-persona commit attribution.
var SCAFFOLD_HTML =
'<div style="max-width:640px">' +
'<p style="color:var(--text-2);font-size:13px;line-height:1.6;margin-bottom:16px">' +
'SSH keys for git operations. Keys are generated on the server — the private key ' +
'is vault-encrypted and never exposed. Copy the public key into your git host.' +
'</p>' +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">' +
'<h4 style="margin:0">SSH Keys</h4>' +
'<button id="gitKeyGenerateBtn" class="btn-small btn-primary">' +
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
' Generate Key' +
'</button>' +
'</div>' +
'<div id="gitKeyList" class="admin-list" style="margin-bottom:16px"></div>' +
'<div id="gitKeyEmpty" style="display:none;color:var(--text-3);font-size:13px;text-align:center;padding:24px 0">' +
'No SSH keys yet. Generate one to get started.' +
'</div>' +
'<div style="margin-top:16px;padding:14px;background:var(--bg-raised);border-radius:var(--radius);border:1px solid var(--border)">' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--accent)"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>' +
'<span style="font-size:12px;font-weight:600">Security</span>' +
'</div>' +
'<p style="font-size:12px;color:var(--text-3);line-height:1.6;margin:0">' +
'Private keys are encrypted with your personal vault key. They are used server-side ' +
'for git push/pull operations and cannot be downloaded or viewed.' +
'</p>' +
'</div>' +
'</div>';
// ── Generate key dialog ──
var GENERATE_HTML =
'<div style="max-width:400px">' +
'<div class="form-group">' +
'<label>Key Name</label>' +
'<input type="text" id="gitKeyName" class="input" placeholder="e.g. GitHub - main" autocomplete="off">' +
'</div>' +
'<div class="form-group" id="gitKeyPersonaGroup" style="display:none">' +
'<label>Persona (optional)</label>' +
'<select id="gitKeyPersona" class="input"><option value="">None — use account identity</option></select>' +
'<p style="font-size:11px;color:var(--text-3);margin:4px 0 0">Commits signed with this key will use the persona\'s name as git author.</p>' +
'</div>' +
'</div>';
async function _loadSettingsGitKeys() {
var body = document.querySelector('.settings-section') || document.getElementById('settingsSection');
if (!body) return;
body.innerHTML = SCAFFOLD_HTML;
var listEl = document.getElementById('gitKeyList');
var emptyEl = document.getElementById('gitKeyEmpty');
var genBtn = document.getElementById('gitKeyGenerateBtn');
await refreshList();
genBtn.addEventListener('click', async function () {
// Show generate dialog
var ok = await new Promise(function (resolve) {
UI.modal('Generate SSH Key', GENERATE_HTML, [
{ label: 'Cancel', variant: 'ghost', action: function () { resolve(false); } },
{ label: 'Generate', variant: 'primary', action: function () { resolve(true); } },
]);
// Load personas for the optional selector
API.get('/personas/mine').then(function (d) {
var personas = (d.data || d.personas || []);
if (personas.length > 0) {
var group = document.getElementById('gitKeyPersonaGroup');
var sel = document.getElementById('gitKeyPersona');
if (group) group.style.display = '';
personas.forEach(function (p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name + ' (@' + p.handle + ')';
sel.appendChild(opt);
});
}
}).catch(function () {});
});
if (!ok) return;
var name = (document.getElementById('gitKeyName') || {}).value || '';
if (!name.trim()) { UI.toast('Key name is required', 'warning'); return; }
var personaId = (document.getElementById('gitKeyPersona') || {}).value || null;
try {
var payload = { name: name.trim() };
if (personaId) payload.persona_id = personaId;
var cred = await API.post('/git-credentials/generate', payload);
UI.toast('SSH key generated', 'success');
// Show the public key for copying
showPublicKey(cred);
await refreshList();
} catch (err) {
UI.toast('Generation failed: ' + (err.message || err), 'error');
}
});
async function refreshList() {
try {
var d = await API.get('/git-credentials');
var creds = d.data || [];
if (creds.length === 0) {
listEl.innerHTML = '';
listEl.style.display = 'none';
emptyEl.style.display = '';
return;
}
listEl.style.display = '';
emptyEl.style.display = 'none';
listEl.innerHTML = creds.map(function (c) {
var ts = new Date(c.created_at);
var dateStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
var fp = c.fingerprint ? '<span style="font-family:var(--mono);font-size:11px;color:var(--text-3)">' + esc(c.fingerprint) + '</span>' : '';
var persona = c.persona_id ? '<span class="badge" style="font-size:10px">persona</span>' : '';
return '<div class="admin-list-item" style="display:flex;align-items:center;gap:10px;padding:10px 14px">' +
'<div style="flex:1">' +
'<div style="font-weight:500;font-size:13px">' + esc(c.name) + ' ' + persona + '</div>' +
'<div style="display:flex;gap:12px;margin-top:2px">' +
'<span class="badge">' + esc(c.auth_type) + '</span>' +
fp +
'<span style="font-size:11px;color:var(--text-3)">' + dateStr + '</span>' +
'</div>' +
'</div>' +
(c.public_key ? '<button class="btn-small" onclick="_gitKeyCopy(\'' + c.id + '\')">Copy Public Key</button>' : '') +
'<button class="btn-small btn-ghost" onclick="_gitKeyDelete(\'' + c.id + '\',\'' + esc(c.name) + '\')">Delete</button>' +
'</div>';
}).join('');
} catch (err) {
listEl.innerHTML = '<div style="color:var(--danger);padding:12px">Failed to load credentials</div>';
}
}
function showPublicKey(cred) {
if (!cred.public_key) return;
UI.modal('Public Key — ' + cred.name,
'<p style="font-size:12px;color:var(--text-2);margin-bottom:8px">Add this key to your git host (GitHub → Settings → SSH Keys):</p>' +
'<textarea readonly style="width:100%;height:80px;font-family:var(--mono);font-size:11px;background:var(--bg-raised);border:1px solid var(--border);border-radius:var(--radius);padding:8px;color:var(--text);resize:none">' + esc(cred.public_key) + '</textarea>' +
'<p style="font-size:11px;color:var(--text-3);margin-top:4px">Fingerprint: ' + esc(cred.fingerprint) + '</p>',
[{ label: 'Done', variant: 'primary' }]
);
}
// Register global handlers for onclick
window._gitKeyCopy = async function (id) {
try {
var d = await API.get('/git-credentials/' + id + '/public-key');
await navigator.clipboard.writeText(d.public_key);
UI.toast('Public key copied to clipboard', 'success');
} catch (err) {
UI.toast('Failed to copy: ' + (err.message || err), 'error');
}
};
window._gitKeyDelete = async function (id, name) {
var ok = await UI.confirm('Delete SSH key "' + name + '"?\n\nWorkspaces using this key will lose git access until reassigned.');
if (!ok) return;
try {
await API.del('/git-credentials/' + id);
UI.toast('Key deleted', 'success');
await refreshList();
} catch (err) {
UI.toast('Delete failed: ' + (err.message || err), 'error');
}
};
}
function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
sb.register('_loadSettingsGitKeys', _loadSettingsGitKeys);

View File

@@ -9,6 +9,7 @@
'<div id="taskAdminTabs" style="display:flex;gap:8px;margin-bottom:16px">' + '<div id="taskAdminTabs" style="display:flex;gap:8px;margin-bottom:16px">' +
'<button class="btn-small btn-primary" id="taskTabList">Tasks</button>' + '<button class="btn-small btn-primary" id="taskTabList">Tasks</button>' +
'<button class="btn-small" id="taskTabConfig">Configuration</button>' + '<button class="btn-small" id="taskTabConfig">Configuration</button>' +
'<button class="btn-small" id="taskTabSystem">+ System Task</button>' +
'</div>' + '</div>' +
'<div id="taskListPane">' + '<div id="taskListPane">' +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">' + '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">' +
@@ -47,6 +48,28 @@
'<button class="btn-small btn-danger" id="taskKillBtn">Kill Active Run</button>' + '<button class="btn-small btn-danger" id="taskKillBtn">Kill Active Run</button>' +
'</div>' + '</div>' +
'<div id="taskRunList" class="admin-list"></div>' + '<div id="taskRunList" class="admin-list"></div>' +
'</div>' +
'<div id="taskSystemPane" style="display:none">' +
'<h4 style="margin:0 0 12px">Create System Task</h4>' +
'<p style="font-size:12px;color:var(--text-2);margin:0 0 16px;line-height:1.5">' +
'System tasks run built-in Go functions on a schedule. No LLM, no provider — ' +
'pure platform operations (cleanup, sweeps, compaction). Admin-only.</p>' +
'<div class="settings-section" style="padding:16px">' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:2"><label>Name</label>' +
'<input type="text" id="sysTaskName" style="width:100%" placeholder="Nightly Session Cleanup"></div>' +
'<div class="form-group" style="flex:1"><label>Function</label>' +
'<select id="sysTaskFunc" style="width:100%"><option value="">Loading…</option></select></div>' +
'</div>' +
'<div id="sysTaskFuncDesc" style="font-size:11px;color:var(--text-3);margin:4px 0 12px;min-height:16px"></div>' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:1"><label>Schedule (cron)</label>' +
'<input type="text" id="sysTaskCron" style="width:100%" placeholder="0 3 * * *"></div>' +
'<div class="form-group" style="flex:1"><label>Timezone</label>' +
'<input type="text" id="sysTaskTZ" style="width:100%" value="UTC"></div>' +
'</div>' +
'<button class="btn-small btn-primary" id="sysTaskCreateBtn" style="margin-top:12px">Create System Task</button>' +
'</div>' +
'</div>'; '</div>';
function showTab(tab) { function showTab(tab) {
@@ -54,8 +77,10 @@
el('taskListPane').style.display = tab === 'list' ? '' : 'none'; el('taskListPane').style.display = tab === 'list' ? '' : 'none';
el('taskConfigPane').style.display = tab === 'config' ? '' : 'none'; el('taskConfigPane').style.display = tab === 'config' ? '' : 'none';
el('taskRunPane').style.display = tab === 'runs' ? '' : 'none'; el('taskRunPane').style.display = tab === 'runs' ? '' : 'none';
el('taskSystemPane').style.display = tab === 'system' ? '' : 'none';
el('taskTabList').className = 'btn-small' + (tab === 'list' ? ' btn-primary' : ''); el('taskTabList').className = 'btn-small' + (tab === 'list' ? ' btn-primary' : '');
el('taskTabConfig').className = 'btn-small' + (tab === 'config' ? ' btn-primary' : ''); el('taskTabConfig').className = 'btn-small' + (tab === 'config' ? ' btn-primary' : '');
el('taskTabSystem').className = 'btn-small' + (tab === 'system' ? ' btn-primary' : '');
} }
function statusBadge(status) { function statusBadge(status) {
@@ -89,9 +114,15 @@
: '<span class="badge badge-muted">paused</span>'; : '<span class="badge badge-muted">paused</span>';
var scope = '<span class="badge">' + t.scope + '</span>'; var scope = '<span class="badge">' + t.scope + '</span>';
var type = '<span class="badge">' + t.task_type + '</span>'; var type = '<span class="badge">' + t.task_type + '</span>';
var schedule = t.schedule === 'once' ? 'One-shot' : t.schedule; var funcBadge = t.task_type === 'system' && t.system_function
var nextRun = t.next_run_at ? new Date(t.next_run_at).toLocaleString() : '\u2014'; ? '<span class="badge" style="background:var(--accent-dim);color:var(--accent)">' + escapeHtml(t.system_function) + '</span>' : '';
var schedule = t.schedule === 'once' ? 'One-shot' : t.schedule === 'webhook' ? 'Webhook trigger' : t.schedule;
var nextRun = t.schedule === 'webhook' ? 'on trigger' : t.next_run_at ? new Date(t.next_run_at).toLocaleString() : '\u2014';
var lastRun = t.last_run_at ? new Date(t.last_run_at).toLocaleString() : 'never'; var lastRun = t.last_run_at ? new Date(t.last_run_at).toLocaleString() : 'never';
var triggerBtn = '';
if (t.schedule === 'webhook' && t.trigger_token) {
triggerBtn = '<button class="btn-small task-trigger-btn" data-token="' + escapeHtml(t.trigger_token) + '" title="Copy Trigger URL">\ud83d\udd17</button>';
}
return '<div class="admin-row" style="display:flex;align-items:center;gap:12px;padding:10px 12px">' + return '<div class="admin-row" style="display:flex;align-items:center;gap:12px;padding:10px 12px">' +
'<div style="flex:2">' + '<div style="flex:2">' +
@@ -100,9 +131,10 @@
escapeHtml(schedule) + ' \u00b7 ' + t.run_count + ' runs \u00b7 last: ' + lastRun + escapeHtml(schedule) + ' \u00b7 ' + t.run_count + ' runs \u00b7 last: ' + lastRun +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div style="display:flex;gap:6px;align-items:center">' + type + scope + active + '</div>' + '<div style="display:flex;gap:6px;align-items:center">' + type + funcBadge + scope + active + '</div>' +
'<div style="font-size:11px;color:var(--text-secondary);min-width:140px;text-align:right">next: ' + nextRun + '</div>' + '<div style="font-size:11px;color:var(--text-secondary);min-width:140px;text-align:right">next: ' + nextRun + '</div>' +
'<div style="display:flex;gap:4px">' + '<div style="display:flex;gap:4px">' +
triggerBtn +
'<button class="btn-small task-run-btn" data-id="' + t.id + '" title="Run Now">\u25b6</button>' + '<button class="btn-small task-run-btn" data-id="' + t.id + '" title="Run Now">\u25b6</button>' +
'<button class="btn-small task-history-btn" data-id="' + t.id + '" data-name="' + escapeHtml(t.name) + '" title="History">\ud83d\udccb</button>' + '<button class="btn-small task-history-btn" data-id="' + t.id + '" data-name="' + escapeHtml(t.name) + '" title="History">\ud83d\udccb</button>' +
'<button class="btn-small btn-danger task-delete-btn" data-id="' + t.id + '" title="Delete">\u2715</button>' + '<button class="btn-small btn-danger task-delete-btn" data-id="' + t.id + '" title="Delete">\u2715</button>' +
@@ -119,6 +151,17 @@
list.querySelectorAll('.task-delete-btn').forEach(function(btn) { list.querySelectorAll('.task-delete-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) { e.stopPropagation(); deleteTask(btn.dataset.id); }); btn.addEventListener('click', function(e) { e.stopPropagation(); deleteTask(btn.dataset.id); });
}); });
list.querySelectorAll('.task-trigger-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
var url = location.origin + (window.__BASE__ || '') + '/api/v1/hooks/t/' + btn.dataset.token;
navigator.clipboard.writeText(url).then(function() {
UI.toast('Trigger URL copied', 'success');
}).catch(function() {
prompt('Trigger URL:', url);
});
});
});
} catch (err) { } catch (err) {
console.error('[task-admin] Failed to load tasks:', err); console.error('[task-admin] Failed to load tasks:', err);
} }
@@ -237,6 +280,62 @@
} }
} }
// ── System functions ──────────────────────
var _sysFuncs = [];
async function loadSystemFuncs() {
try {
var resp = await API._get('/api/v1/admin/system-functions');
_sysFuncs = resp.data || [];
var sel = document.getElementById('sysTaskFunc');
sel.innerHTML = '<option value="">— select function —</option>';
_sysFuncs.forEach(function(f) {
var opt = document.createElement('option');
opt.value = f.name;
opt.textContent = f.name;
sel.appendChild(opt);
});
sel.addEventListener('change', function() {
var desc = document.getElementById('sysTaskFuncDesc');
var found = _sysFuncs.find(function(f) { return f.name === sel.value; });
desc.textContent = found ? found.description : '';
});
} catch (err) {
console.error('[task-admin] Failed to load system functions:', err);
}
// Wire create button
document.getElementById('sysTaskCreateBtn').addEventListener('click', createSystemTask);
}
async function createSystemTask() {
var name = document.getElementById('sysTaskName').value.trim();
var func_ = document.getElementById('sysTaskFunc').value;
var cron = document.getElementById('sysTaskCron').value.trim();
var tz = document.getElementById('sysTaskTZ').value.trim() || 'UTC';
if (!name) { UI.toast('Name is required', 'warning'); return; }
if (!func_) { UI.toast('Select a system function', 'warning'); return; }
if (!cron) { UI.toast('Schedule (cron) is required', 'warning'); return; }
try {
await API._post('/api/v1/tasks', {
name: name,
task_type: 'system',
system_function: func_,
schedule: cron,
timezone: tz,
scope: 'global',
output_mode: 'channel',
});
UI.toast('System task created', 'success');
showTab('list');
loadTasks();
} catch (err) {
UI.toast(err.message || 'Failed to create system task', 'error');
}
}
sb.register('_loadAdminTasks', async function() { sb.register('_loadAdminTasks', async function() {
var container = document.getElementById('adminDynamic'); var container = document.getElementById('adminDynamic');
if (!container) return; if (!container) return;
@@ -244,6 +343,7 @@
document.getElementById('taskTabList').addEventListener('click', function() { showTab('list'); loadTasks(); }); document.getElementById('taskTabList').addEventListener('click', function() { showTab('list'); loadTasks(); });
document.getElementById('taskTabConfig').addEventListener('click', function() { showTab('config'); loadConfig(); }); document.getElementById('taskTabConfig').addEventListener('click', function() { showTab('config'); loadConfig(); });
document.getElementById('taskTabSystem').addEventListener('click', function() { showTab('system'); loadSystemFuncs(); });
document.getElementById('taskRunBackBtn').addEventListener('click', function() { showTab('list'); }); document.getElementById('taskRunBackBtn').addEventListener('click', function() { showTab('list'); });
document.getElementById('taskKillBtn').addEventListener('click', killRun); document.getElementById('taskKillBtn').addEventListener('click', killRun);
document.getElementById('taskCfgSaveBtn').addEventListener('click', saveConfig); document.getElementById('taskCfgSaveBtn').addEventListener('click', saveConfig);

View File

@@ -12,6 +12,7 @@
{ label: 'Every hour', cron: '0 * * * *' }, { label: 'Every hour', cron: '0 * * * *' },
{ label: 'Weekly (Monday 9am)', cron: '0 9 * * 1' }, { label: 'Weekly (Monday 9am)', cron: '0 9 * * 1' },
{ label: 'Monthly (1st midnight)', cron: '0 0 1 * *' }, { label: 'Monthly (1st midnight)', cron: '0 0 1 * *' },
{ label: 'Webhook trigger', cron: 'webhook' },
{ label: 'Custom...', cron: '' }, { label: 'Custom...', cron: '' },
]; ];
@@ -73,12 +74,16 @@
html += tasks.map(function(t) { html += tasks.map(function(t) {
var status = t.is_active ? '<span class="badge badge-success">active</span>' : '<span class="badge badge-muted">paused</span>'; var status = t.is_active ? '<span class="badge badge-success">active</span>' : '<span class="badge badge-muted">paused</span>';
var teamBadge = t._source === 'team' ? '<span class="badge" style="background:var(--accent-dim);color:var(--accent)">' + esc(t._teamName || 'team') + '</span>' : ''; var teamBadge = t._source === 'team' ? '<span class="badge" style="background:var(--accent-dim);color:var(--accent)">' + esc(t._teamName || 'team') + '</span>' : '';
var sched = t.schedule === 'once' ? 'One-shot' : t.schedule; var sched = t.schedule === 'once' ? 'One-shot' : t.schedule === 'webhook' ? 'Webhook trigger' : t.schedule;
var lastStatus = ''; var lastStatus = '';
if (t.last_run_at) { if (t.last_run_at) {
lastStatus = ' \u00b7 last: ' + new Date(t.last_run_at).toLocaleString(); lastStatus = ' \u00b7 last: ' + new Date(t.last_run_at).toLocaleString();
} }
var nextRun = t.next_run_at ? new Date(t.next_run_at).toLocaleString() : '\u2014'; var nextRun = t.schedule === 'webhook' ? 'on trigger' : t.next_run_at ? new Date(t.next_run_at).toLocaleString() : '\u2014';
var triggerBtn = '';
if (t.schedule === 'webhook' && t.trigger_token) {
triggerBtn = '<button class="btn-small task-trigger-btn" data-token="' + esc(t.trigger_token) + '" title="Copy Trigger URL">\ud83d\udd17</button>';
}
return '<div class="settings-section" style="padding:12px 16px;margin-bottom:8px">' + return '<div class="settings-section" style="padding:12px 16px;margin-bottom:8px">' +
'<div style="display:flex;align-items:center;gap:12px">' + '<div style="display:flex;align-items:center;gap:12px">' +
@@ -89,6 +94,7 @@
'</div>' + '</div>' +
'<div style="display:flex;gap:6px;align-items:center">' + '<div style="display:flex;gap:6px;align-items:center">' +
teamBadge + status + teamBadge + status +
triggerBtn +
'<button class="btn-small task-run-btn" data-id="' + t.id + '" title="Run Now">\u25b6</button>' + '<button class="btn-small task-run-btn" data-id="' + t.id + '" title="Run Now">\u25b6</button>' +
'<button class="btn-small task-toggle-btn" data-id="' + t.id + '" data-active="' + t.is_active + '" title="' + (t.is_active ? 'Pause' : 'Resume') + '">' + (t.is_active ? '\u23f8' : '\u25b6') + '</button>' + '<button class="btn-small task-toggle-btn" data-id="' + t.id + '" data-active="' + t.is_active + '" title="' + (t.is_active ? 'Pause' : 'Resume') + '">' + (t.is_active ? '\u23f8' : '\u25b6') + '</button>' +
'<button class="btn-small btn-danger task-del-btn" data-id="' + t.id + '" title="Delete">\u2715</button>' + '<button class="btn-small btn-danger task-del-btn" data-id="' + t.id + '" title="Delete">\u2715</button>' +
@@ -123,6 +129,16 @@
mount.querySelectorAll('.task-del-btn').forEach(function(btn) { mount.querySelectorAll('.task-del-btn').forEach(function(btn) {
btn.addEventListener('click', function() { deleteTask(btn.dataset.id); }); btn.addEventListener('click', function() { deleteTask(btn.dataset.id); });
}); });
mount.querySelectorAll('.task-trigger-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var url = location.origin + (window.__BASE__ || '') + '/api/v1/hooks/t/' + btn.dataset.token;
navigator.clipboard.writeText(url).then(function() {
UI.toast('Trigger URL copied', 'success');
}).catch(function() {
prompt('Trigger URL:', url);
});
});
});
} catch (err) { } catch (err) {
mount.innerHTML = '<p style="color:var(--danger)">Failed to load tasks: ' + esc(err.message) + '</p>'; mount.innerHTML = '<p style="color:var(--danger)">Failed to load tasks: ' + esc(err.message) + '</p>';
} }
@@ -164,6 +180,17 @@
'<div class="form-group" style="flex:1" id="taskWebhookGroup" style="display:none"><label>Webhook URL</label>' + '<div class="form-group" style="flex:1" id="taskWebhookGroup" style="display:none"><label>Webhook URL</label>' +
'<input type="text" id="taskWebhookURL" style="width:100%" placeholder="https://..."></div>' + '<input type="text" id="taskWebhookURL" style="width:100%" placeholder="https://..."></div>' +
'</div>' + '</div>' +
'<div class="form-group" style="margin-top:8px" id="taskWebhookSecretGroup" style="display:none"><label>Webhook Secret (HMAC signing)</label>' +
'<input type="text" id="taskWebhookSecret" style="width:100%" placeholder="auto-generated if empty">' +
'<p style="font-size:11px;color:var(--text-3);margin:2px 0 0">Used to sign outbound webhook payloads. Leave blank for auto-generated secret.</p>' +
'</div>' +
'<div id="taskWebhookTriggerInfo" style="display:none;margin-top:12px;padding:12px;background:var(--bg-raised);border-radius:var(--radius);border:1px solid var(--border)">' +
'<div style="font-weight:600;font-size:12px;margin-bottom:4px">' +
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>' +
' Webhook-triggered task</div>' +
'<p style="font-size:12px;color:var(--text-2);margin:0;line-height:1.5">This task runs when an external service sends a POST request to its trigger URL. ' +
'The trigger URL will be shown after creation — copy it into your CI, cron, or another task\\u2019s webhook URL for task-to-task chaining.</p>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' + '<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Max Tokens</label>' + '<div class="form-group" style="flex:1"><label>Max Tokens</label>' +
'<input type="number" id="taskMaxTokens" value="" style="width:100%" placeholder="default"></div>' + '<input type="number" id="taskMaxTokens" value="" style="width:100%" placeholder="default"></div>' +
@@ -183,19 +210,28 @@
// Toggle custom cron visibility // Toggle custom cron visibility
var preset = document.getElementById('taskPreset'); var preset = document.getElementById('taskPreset');
var customGroup = document.getElementById('taskCustomCronGroup'); var customGroup = document.getElementById('taskCustomCronGroup');
var triggerInfo = document.getElementById('taskWebhookTriggerInfo');
preset.addEventListener('change', function() { preset.addEventListener('change', function() {
customGroup.style.display = preset.value === '' ? '' : 'none'; var isWebhook = preset.value === 'webhook';
if (preset.value !== '') document.getElementById('taskCron').value = preset.value; var isCustom = preset.value === '';
customGroup.style.display = isCustom ? '' : 'none';
triggerInfo.style.display = isWebhook ? '' : 'none';
if (!isWebhook && !isCustom) document.getElementById('taskCron').value = preset.value;
if (isWebhook) document.getElementById('taskCron').value = 'webhook';
}); });
// Initial state // Initial state
customGroup.style.display = preset.value === '' ? '' : 'none'; customGroup.style.display = preset.value === '' ? '' : 'none';
if (preset.value !== '') document.getElementById('taskCron').value = preset.value; triggerInfo.style.display = preset.value === 'webhook' ? '' : 'none';
if (preset.value !== '' && preset.value !== 'webhook') document.getElementById('taskCron').value = preset.value;
// Toggle webhook URL visibility // Toggle webhook URL and secret visibility
var output = document.getElementById('taskOutput'); var output = document.getElementById('taskOutput');
var webhookGroup = document.getElementById('taskWebhookGroup'); var webhookGroup = document.getElementById('taskWebhookGroup');
var webhookSecretGroup = document.getElementById('taskWebhookSecretGroup');
output.addEventListener('change', function() { output.addEventListener('change', function() {
webhookGroup.style.display = output.value === 'webhook' ? '' : 'none'; var isWH = output.value === 'webhook';
webhookGroup.style.display = isWH ? '' : 'none';
webhookSecretGroup.style.display = isWH ? '' : 'none';
}); });
} }
@@ -227,12 +263,39 @@
if (payload.output_mode === 'webhook' && webhookURL) { if (payload.output_mode === 'webhook' && webhookURL) {
payload.webhook_url = webhookURL; payload.webhook_url = webhookURL;
} }
var webhookSecret = document.getElementById('taskWebhookSecret')?.value;
if (payload.output_mode === 'webhook' && webhookSecret) {
payload.webhook_secret = webhookSecret;
}
try { try {
await API._post('/api/v1/tasks', payload); var created = await API._post('/api/v1/tasks', payload);
UI.toast('Task created', 'success'); UI.toast('Task created', 'success');
// Show trigger URL for webhook-scheduled tasks
if (created.schedule === 'webhook' && created.trigger_token) {
var triggerURL = location.origin + (window.__BASE__ || '') + '/api/v1/hooks/t/' + created.trigger_token;
UI.modal('Webhook Trigger URL',
'<p style="font-size:12px;color:var(--text-2);margin-bottom:8px">POST to this URL to trigger the task. ' +
'You can use this in CI pipelines, cron jobs, or as another task\\u2019s webhook URL for chaining.</p>' +
'<div style="display:flex;gap:8px;align-items:center">' +
'<input type="text" readonly value="' + esc(triggerURL) + '" style="flex:1;font-family:var(--mono);font-size:11px" id="_triggerURLInput">' +
'<button class="btn-small btn-primary" id="_copyTriggerBtn">Copy</button>' +
'</div>' +
'<p style="font-size:11px;color:var(--text-3);margin-top:8px">The request body is passed to the task as trigger_payload. ' +
'For task-to-task chaining: set Task A\\u2019s webhook_url to Task B\\u2019s trigger URL.</p>',
[{ label: 'Done', variant: 'primary' }]
);
setTimeout(function() {
var copyBtn = document.getElementById('_copyTriggerBtn');
if (copyBtn) copyBtn.addEventListener('click', function() {
var input = document.getElementById('_triggerURLInput');
if (input) { navigator.clipboard.writeText(input.value); UI.toast('Copied', 'success'); }
});
}, 100);
}
loadTaskList(); loadTaskList();
// Refresh sidebar
if (typeof TaskSidebar !== 'undefined') TaskSidebar.refresh(); if (typeof TaskSidebar !== 'undefined') TaskSidebar.refresh();
} catch (err) { } catch (err) {
UI.toast(err.message || 'Failed to create task', 'error'); UI.toast(err.message || 'Failed to create task', 'error');

View File

@@ -11,7 +11,7 @@ const ADMIN_SECTIONS = {
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'], ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
workflows: ['workflows', 'tasks'], workflows: ['workflows', 'tasks'],
routing: ['health', 'routing', 'capabilities'], routing: ['health', 'routing', 'capabilities'],
system: ['settings', 'storage', 'extensions', 'channels', 'surfaces'], system: ['settings', 'storage', 'extensions', 'channels', 'surfaces', 'broadcast'],
monitoring: ['usage', 'audit', 'stats'], monitoring: ['usage', 'audit', 'stats'],
}; };
@@ -20,7 +20,7 @@ const ADMIN_LABELS = {
providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory', providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
health: 'Health', routing: 'Routing', capabilities: 'Capabilities', health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
workflows: 'Workflows', tasks: 'Tasks', workflows: 'Workflows', tasks: 'Tasks',
settings: 'Settings', storage: 'Storage', extensions: 'Extensions', channels: 'Channels', surfaces: 'Surfaces', settings: 'Settings', storage: 'Storage', extensions: 'Extensions', channels: 'Channels', surfaces: 'Surfaces', broadcast: 'Broadcast',
usage: 'Usage', audit: 'Audit', stats: 'Stats', usage: 'Usage', audit: 'Audit', stats: 'Stats',
}; };
@@ -54,6 +54,7 @@ const ADMIN_LOADERS = {
channels: () => UI.loadAdminChannels(), channels: () => UI.loadAdminChannels(),
workflows: () => typeof _loadAdminWorkflows === 'function' ? _loadAdminWorkflows() : null, workflows: () => typeof _loadAdminWorkflows === 'function' ? _loadAdminWorkflows() : null,
tasks: () => typeof _loadAdminTasks === 'function' ? _loadAdminTasks() : null, tasks: () => typeof _loadAdminTasks === 'function' ? _loadAdminTasks() : null,
broadcast: () => typeof _loadAdminBroadcast === 'function' ? _loadAdminBroadcast() : null,
}; };
// Find which category a section belongs to // Find which category a section belongs to

View File

@@ -709,6 +709,7 @@ const UI = {
renderMessages(messages) { renderMessages(messages) {
const el = document.getElementById('chatMessages'); const el = document.getElementById('chatMessages');
if (!messages || messages.length === 0) { if (!messages || messages.length === 0) {
if (typeof VirtualScroll !== 'undefined') VirtualScroll.destroy();
this.showEmptyState(); this.showEmptyState();
return; return;
} }
@@ -719,7 +720,30 @@ const UI = {
if (_isSummaryMessage(messages[i])) summaryIdx = i; if (_isSummaryMessage(messages[i])) summaryIdx = i;
} }
// Render: if summary exists, show collapsed-history toggle + summary + messages after // v0.28.6: Virtual scroll for long conversations
if (typeof VirtualScroll !== 'undefined' && el) {
// Lazy bind — only once per container
if (!VirtualScroll._container) {
const self = this;
const postRender = (container) => {
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(container);
if (typeof loadAuthImages === 'function') loadAuthImages(container);
};
VirtualScroll.bind(
el,
(msg, idx, dimmed) => self._messageHTML(msg, idx, dimmed),
(msg) => self._summaryHTML(msg),
postRender
);
}
if (VirtualScroll.setMessages(messages, summaryIdx)) {
this._scrollToBottom(true);
return; // VirtualScroll handled it
}
// Falls through for short conversations
}
// Original path for short conversations
let html = ''; let html = '';
if (summaryIdx >= 0) { if (summaryIdx >= 0) {
const hiddenCount = messages.filter((m, i) => i < summaryIdx && m.role !== 'system').length; const hiddenCount = messages.filter((m, i) => i < summaryIdx && m.role !== 'system').length;

277
src/js/virtual-scroll.js Normal file
View File

@@ -0,0 +1,277 @@
// ==========================================
// Chat Switchboard — Virtual Scroll
// ==========================================
// Viewport-windowed message rendering for long conversations.
// Renders a window of messages around the viewport. As the user
// scrolls, older/newer chunks are rendered on demand and distant
// messages are removed from the DOM to keep memory bounded.
//
// Design: sentinel-based (IntersectionObserver on top/bottom markers)
// rather than scroll-listener based. No jank, no debounce.
//
// Integration: wraps UI._messageHTML() — no rendering duplication.
// The container element (#chatMessages) is unchanged.
//
// Streaming: the actively-streaming message is always in DOM at the
// bottom, managed by streamResponse(). VirtualScroll leaves it alone.
const WINDOW_SIZE = 80; // max messages in DOM at once
const BUFFER = 20; // render this many beyond viewport edge
const PREPEND_CHUNK = 40; // load this many when scrolling up
const LOAD_MORE_THRESHOLD = 100; // only activate for conversations this long
class VirtualScroll {
constructor() {
this._messages = []; // full message array (data, not DOM)
this._renderFn = null; // (msg, index, dimmed) => html string
this._summaryFn = null; // (msg) => html string
this._postRenderFn = null; // (container) => void
this._container = null;
this._observer = null;
this._topSentinel = null;
this._renderedRange = { start: 0, end: 0 }; // indices into _messages
this._enabled = false;
this._scrollLock = false; // prevent observer re-entry during DOM mutation
this._summaryIdx = -1;
}
// ── Configuration ────────────────────────
/**
* Bind to a container and rendering functions.
* @param {HTMLElement} container - the #chatMessages element
* @param {Function} renderFn - (msg, index, dimmed?) => html string
* @param {Function} summaryFn - (msg) => html string
* @param {Function} postRenderFn - (container) => void (extensions, images)
*/
bind(container, renderFn, summaryFn, postRenderFn) {
this._container = container;
this._renderFn = renderFn;
this._summaryFn = summaryFn;
this._postRenderFn = postRenderFn;
}
// ── Public API ───────────────────────────
/**
* Set messages and render. Called by renderMessages().
* For short conversations, returns false (caller should use
* the original innerHTML path). For long ones, renders the
* tail window and returns true.
*/
setMessages(messages, summaryIdx) {
this._messages = messages;
this._summaryIdx = summaryIdx;
if (!this._container || !this._renderFn) return false;
// Only virtualize long conversations
const visible = messages.filter(m => m.role !== 'system');
if (visible.length < LOAD_MORE_THRESHOLD) {
this._teardown();
this._enabled = false;
return false;
}
this._enabled = true;
this._renderTail();
return true;
}
/**
* Whether virtual scroll is currently active.
*/
get active() { return this._enabled; }
/**
* Clean up observers. Call when switching conversations.
*/
destroy() {
this._teardown();
this._messages = [];
this._enabled = false;
}
// ── Internal: Render ─────────────────────
/**
* Render the tail (most recent) window of messages.
* This is the initial render — user sees the latest messages.
*/
_renderTail() {
const msgs = this._filteredMessages();
const total = msgs.length;
const start = Math.max(0, total - WINDOW_SIZE);
const end = total;
this._renderedRange = { start, end };
this._buildDOM(msgs, start, end);
}
/**
* Prepend older messages when user scrolls to top.
*/
_prependChunk() {
if (this._scrollLock) return;
const msgs = this._filteredMessages();
const { start } = this._renderedRange;
if (start <= 0) return; // already showing all
this._scrollLock = true;
const newStart = Math.max(0, start - PREPEND_CHUNK);
const container = this._container;
// Remember scroll position to restore after prepend
const scrollBottom = container.scrollHeight - container.scrollTop;
// Build HTML for the new chunk
let html = '';
for (let i = newStart; i < start; i++) {
html += this._renderOne(msgs[i], i);
}
// Insert after top sentinel, before existing messages
const sentinel = this._topSentinel;
if (sentinel) {
const frag = document.createRange().createContextualFragment(html);
sentinel.after(frag);
}
// Trim bottom if DOM is too large
const totalRendered = this._renderedRange.end - newStart;
if (totalRendered > WINDOW_SIZE + BUFFER) {
const excess = totalRendered - WINDOW_SIZE;
this._removeFromBottom(excess);
this._renderedRange.end -= excess;
}
this._renderedRange.start = newStart;
// Restore scroll position (prevent jump)
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight - scrollBottom;
if (this._postRenderFn) this._postRenderFn(container);
this._scrollLock = false;
this._updateSentinelState();
});
}
// ── Internal: DOM ────────────────────────
_buildDOM(msgs, start, end) {
const container = this._container;
let html = '';
// Top sentinel (triggers prepend on intersection)
html += '<div id="vs-top-sentinel" class="vs-sentinel" style="height:1px"></div>';
// "Load more" indicator
if (start > 0) {
html += '<div class="vs-load-more" id="vs-load-more" style="text-align:center;padding:12px 0">' +
'<span style="font-size:12px;color:var(--text-3)">' +
start + ' earlier messages</span></div>';
}
// Summary boundary
if (this._summaryIdx >= 0) {
const summaryMsg = this._messages[this._summaryIdx];
if (summaryMsg && this._summaryFn) {
html += this._summaryFn(summaryMsg);
}
}
// Messages
for (let i = start; i < end; i++) {
html += this._renderOne(msgs[i], i);
}
container.innerHTML = html;
// Set up intersection observer on top sentinel
this._setupObserver();
if (this._postRenderFn) this._postRenderFn(container);
}
_renderOne(msg, index) {
if (!msg) return '';
// Skip system messages and summary messages (summary handled separately)
if (msg.role === 'system') return '';
if (msg._isSummary) return '';
const dimmed = this._summaryIdx >= 0 && index < this._summaryIdx;
return this._renderFn(msg, index, dimmed);
}
_removeFromBottom(count) {
const container = this._container;
const messages = container.querySelectorAll('.message');
const toRemove = Array.from(messages).slice(-count);
toRemove.forEach(el => el.remove());
}
// ── Observer ─────────────────────────────
_setupObserver() {
this._teardownObserver();
this._topSentinel = this._container.querySelector('#vs-top-sentinel');
if (!this._topSentinel) return;
this._observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting && entry.target.id === 'vs-top-sentinel') {
this._prependChunk();
}
}
}, {
root: this._container,
rootMargin: '200px 0px 0px 0px', // trigger 200px before reaching top
});
this._observer.observe(this._topSentinel);
}
_updateSentinelState() {
const loadMore = this._container.querySelector('#vs-load-more');
if (loadMore) {
if (this._renderedRange.start <= 0) {
loadMore.style.display = 'none';
} else {
loadMore.querySelector('span').textContent =
this._renderedRange.start + ' earlier messages';
}
}
}
_teardownObserver() {
if (this._observer) {
this._observer.disconnect();
this._observer = null;
}
this._topSentinel = null;
}
_teardown() {
this._teardownObserver();
this._renderedRange = { start: 0, end: 0 };
}
// ── Helpers ──────────────────────────────
/**
* Returns messages with summary detection metadata.
* Mirrors the filtering logic from renderMessages().
*/
_filteredMessages() {
return this._messages.filter(m => m.role !== 'system');
}
}
// ── Singleton ────────────────────────────
const virtualScroll = new VirtualScroll();
window.VirtualScroll = virtualScroll;
sb.register('VirtualScroll', virtualScroll);

View File

@@ -124,5 +124,30 @@
T.assertStatus(d, 404, 'delete non-existent'); T.assertStatus(d, 404, 'delete non-existent');
}); });
// ── Admin broadcast (v0.28.6) ──
await T.test('crud', 'notifications', 'POST /admin/notifications/broadcast', async function () {
var d = await T.apiPost('/admin/notifications/broadcast', {
title: 'ICD Test Broadcast',
message: 'Automated test broadcast.',
level: 'info'
});
T.assertHasKey(d, 'message', 'broadcast response');
T.assertHasKey(d, 'count', 'broadcast response');
T.assert(d.count >= 1, 'count should be >= 1');
});
await T.test('crud', 'notifications', 'POST /admin/notifications/broadcast (missing title → 400)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/admin/notifications/broadcast', { message: 'no title' });
T.assertStatus(d, 400, 'missing title');
});
await T.test('crud', 'notifications', 'POST /admin/notifications/broadcast (invalid level → 400)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/admin/notifications/broadcast', { title: 'x', message: 'x', level: 'extreme' });
T.assertStatus(d, 400, 'invalid level');
});
}; };
})(); })();

View File

@@ -249,5 +249,66 @@
} }
} }
// ── System functions (v0.28.6) ──
await T.test('crud', 'tasks', 'GET /admin/system-functions', async function () {
var d = await T.apiGet('/admin/system-functions');
T.assertHasKey(d, 'data', 'system-functions');
T.assert(Array.isArray(d.data), 'data should be array');
T.assert(d.data.length >= 4, 'should have at least 4 built-in functions, got ' + d.data.length);
var names = d.data.map(function(f) { return f.name; });
T.assert(names.indexOf('session_cleanup') >= 0, 'should include session_cleanup');
T.assert(names.indexOf('staleness_check') >= 0, 'should include staleness_check');
T.assert(names.indexOf('retention_sweep') >= 0, 'should include retention_sweep');
T.assert(names.indexOf('health_prune') >= 0, 'should include health_prune');
// Verify shape
T.assert(typeof d.data[0].name === 'string', 'name should be string');
T.assert(typeof d.data[0].description === 'string', 'description should be string');
});
var sysTaskId = null;
await T.test('crud', 'tasks', 'POST /tasks (create system task)', async function () {
var d = await T.apiPost('/tasks', {
name: testTag + '-system-task',
task_type: 'system',
system_function: 'health_prune',
schedule: '0 3 * * *',
scope: 'global',
});
T.assert(d.id, 'should return id');
T.assert(d.task_type === 'system', 'task_type should be system');
T.assert(d.system_function === 'health_prune', 'system_function should be health_prune');
sysTaskId = d.id;
T.registerCleanup(function () { if (sysTaskId) return T.safeDelete('/tasks/' + sysTaskId); });
});
if (sysTaskId) {
await T.test('crud', 'tasks', 'DELETE /tasks/:id (cleanup system task)', async function () {
await T.safeDelete('/tasks/' + sysTaskId);
sysTaskId = null;
});
}
await T.test('crud', 'tasks', 'POST /tasks (system task — invalid function → 400)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/tasks', {
name: 'bad-func',
task_type: 'system',
system_function: 'nonexistent_func',
schedule: '0 3 * * *',
});
T.assertStatus(d, 400, 'invalid system function');
});
await T.test('crud', 'tasks', 'POST /tasks (system task — missing function → 400)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/tasks', {
name: 'no-func',
task_type: 'system',
schedule: '0 3 * * *',
});
T.assertStatus(d, 400, 'missing system_function');
});
}; };
})(); })();

View File

@@ -25,6 +25,49 @@
T.assert(Array.isArray(d.data), 'data must be array'); T.assert(Array.isArray(d.data), 'data must be array');
}); });
// ── POST /git-credentials/generate — server-side keygen (v0.28.6) ──
var generatedKeyId = null;
await T.test('crud', 'workspaces', 'POST /git-credentials/generate', async function () {
var d = await T.apiPost('/git-credentials/generate', { name: 'ICD Test Key' });
T.assert(d.id, 'should return id');
T.assert(d.auth_type === 'ssh_key', 'auth_type should be ssh_key');
T.assert(d.public_key && d.public_key.length > 0, 'should return public_key');
T.assert(d.fingerprint && d.fingerprint.startsWith('SHA256:'), 'should return SHA256 fingerprint');
T.assert(!d.encrypted_data, 'must NOT expose encrypted_data');
T.assert(!d.nonce, 'must NOT expose nonce');
generatedKeyId = d.id;
T.registerCleanup(function () { if (generatedKeyId) return T.safeDelete('/git-credentials/' + generatedKeyId); });
});
if (generatedKeyId) {
await T.test('crud', 'workspaces', 'GET /git-credentials/:id/public-key', async function () {
var d = await T.apiGet('/git-credentials/' + generatedKeyId + '/public-key');
T.assertHasKey(d, 'public_key', 'public-key response');
T.assertHasKey(d, 'fingerprint', 'public-key response');
T.assert(d.public_key.length > 0, 'public_key should be non-empty');
});
await T.test('crud', 'workspaces', 'GET /git-credentials (list after generate)', async function () {
var d = await T.apiGet('/git-credentials');
var found = (d.data || []).find(function (c) { return c.id === generatedKeyId; });
T.assert(found, 'generated key should appear in list');
T.assert(found.public_key, 'list item should have public_key');
T.assert(found.fingerprint, 'list item should have fingerprint');
});
await T.test('crud', 'workspaces', 'DELETE /git-credentials/:id', async function () {
var d = await T.apiDelete('/git-credentials/' + generatedKeyId);
T.assert(d.deleted === true, 'should return deleted: true');
generatedKeyId = null;
});
}
await T.test('crud', 'workspaces', 'POST /git-credentials/generate (missing name → 400)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/git-credentials/generate', {});
T.assertStatus(d, 400, 'missing name');
});
// ── Workspace CRUD ── // ── Workspace CRUD ──
var wsId = null; var wsId = null;
await T.test('crud', 'workspaces', 'POST /workspaces (create)', async function () { await T.test('crud', 'workspaces', 'POST /workspaces (create)', async function () {

View File

@@ -165,7 +165,7 @@
S.safeConfig = { id: 'string', name: 'string', provider: 'string', is_active: 'bool', has_key: 'bool' }; S.safeConfig = { id: 'string', name: 'string', provider: 'string', is_active: 'bool', has_key: 'bool' };
S.catalogModel = { model_id: 'string', provider: 'string' }; S.catalogModel = { model_id: 'string', provider: 'string' };
S.modelEnabled = { id: 'string', model_id: 'string', display_name: 'string', model_type: 'string', source: 'string', provider_config_id: 'string', provider_name: 'string', provider_type: 'string', capabilities: 'object', scope: 'string', is_persona: 'bool', hidden: 'bool' }; S.modelEnabled = { id: 'string', model_id: 'string', display_name: 'string', model_type: 'string', source: 'string', provider_config_id: 'string', provider_name: 'string', provider_type: 'string', capabilities: 'object', scope: 'string', is_persona: 'bool', hidden: 'bool' };
S.modelPreference = { id: 'string', user_id: 'string', model_id: 'string', hidden: 'bool', sort_order: 'number', created_at: 'string', updated_at: 'string' }; S.modelPreference = { id: 'string', user_id: 'string', model_id: 'string', provider_config_id: 'string', hidden: 'bool', sort_order: 'number', created_at: 'string', updated_at: 'string' };
S.task = { id: 'string', name: 'string', task_type: 'string', schedule: 'string', is_active: 'bool', created_at: 'string' }; S.task = { id: 'string', name: 'string', task_type: 'string', schedule: 'string', is_active: 'bool', created_at: 'string' };
S.taskFull = { id: 'string', owner_id: 'string', name: 'string', task_type: 'string', scope: 'string', schedule: 'string', timezone: 'string', is_active: 'bool', max_tokens: 'number', max_tool_calls: 'number', max_wall_clock: 'number', output_mode: 'string', run_count: 'number', created_at: 'string', updated_at: 'string' }; S.taskFull = { id: 'string', owner_id: 'string', name: 'string', task_type: 'string', scope: 'string', schedule: 'string', timezone: 'string', is_active: 'bool', max_tokens: 'number', max_tool_calls: 'number', max_wall_clock: 'number', output_mode: 'string', run_count: 'number', created_at: 'string', updated_at: 'string' };
S.taskRun = { id: 'string', task_id: 'string', status: 'string', started_at: 'string' }; S.taskRun = { id: 'string', task_id: 'string', status: 'string', started_at: 'string' };