diff --git a/CHANGELOG.md b/CHANGELOG.md
index 432a94a..e5eb969 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,83 @@
# 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
### Summary
diff --git a/VERSION b/VERSION
index 16b6bce..2aa6ed7 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.28.5
+0.28.6
diff --git a/chart/Chart.yaml b/chart/Chart.yaml
new file mode 100644
index 0000000..f40e907
--- /dev/null
+++ b/chart/Chart.yaml
@@ -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
diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt
new file mode 100644
index 0000000..04c688a
--- /dev/null
+++ b/chart/templates/NOTES.txt
@@ -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 }}
diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl
new file mode 100644
index 0000000..90681d7
--- /dev/null
+++ b/chart/templates/_helpers.tpl
@@ -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 }}
diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml
new file mode 100644
index 0000000..026241c
--- /dev/null
+++ b/chart/templates/configmap.yaml
@@ -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 }}
diff --git a/chart/templates/deployment-backend.yaml b/chart/templates/deployment-backend.yaml
new file mode 100644
index 0000000..90a2168
--- /dev/null
+++ b/chart/templates/deployment-backend.yaml
@@ -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 }}
diff --git a/chart/templates/deployment-frontend.yaml b/chart/templates/deployment-frontend.yaml
new file mode 100644
index 0000000..9f0c90b
--- /dev/null
+++ b/chart/templates/deployment-frontend.yaml
@@ -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
diff --git a/chart/templates/ingress.yaml b/chart/templates/ingress.yaml
new file mode 100644
index 0000000..11b5a8a
--- /dev/null
+++ b/chart/templates/ingress.yaml
@@ -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 }}
diff --git a/chart/templates/pvc.yaml b/chart/templates/pvc.yaml
new file mode 100644
index 0000000..beeb9be
--- /dev/null
+++ b/chart/templates/pvc.yaml
@@ -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 }}
diff --git a/chart/templates/secret.yaml b/chart/templates/secret.yaml
new file mode 100644
index 0000000..0f6b490
--- /dev/null
+++ b/chart/templates/secret.yaml
@@ -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 }}
diff --git a/chart/templates/services.yaml b/chart/templates/services.yaml
new file mode 100644
index 0000000..6000342
--- /dev/null
+++ b/chart/templates/services.yaml
@@ -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
diff --git a/chart/values.yaml b/chart/values.yaml
new file mode 100644
index 0000000..43146c3
--- /dev/null
+++ b/chart/values.yaml
@@ -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: []
diff --git a/docs/ICD/admin.md b/docs/ICD/admin.md
index c5e4aa5..fe3b243 100644
--- a/docs/ICD/admin.md
+++ b/docs/ICD/admin.md
@@ -234,3 +234,15 @@ POST /admin/tasks/:id/run → force run
POST /admin/tasks/:id/kill → cancel active run
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.
diff --git a/docs/ICD/notifications.md b/docs/ICD/notifications.md
index 727d3e5..4b96593 100644
--- a/docs/ICD/notifications.md
+++ b/docs/ICD/notifications.md
@@ -91,6 +91,42 @@ POST /admin/notifications/test-email → send test email to requesting ad
Requires admin role. Loads SMTP config from platform settings,
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
| 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.read` | `{"id": "uuid"}` | Single notification 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
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.
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
diff --git a/docs/ICD/tasks.md b/docs/ICD/tasks.md
index c7d83a3..b0371f1 100644
--- a/docs/ICD/tasks.md
+++ b/docs/ICD/tasks.md
@@ -65,8 +65,17 @@ POST /tasks
```
`task_type`: `prompt` (direct LLM execution), `workflow` (deferred —
-not yet implemented; creation is rejected at the API), or `action`
-(no LLM — relay/webhook only; requires `task.action` permission).
+not yet implemented; creation is rejected at the API), `action`
+(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
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 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-scoped tasks visible to all team members, manageable by team admins.
@@ -240,6 +269,30 @@ DELETE /admin/tasks/:id
**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
```json
@@ -250,7 +303,8 @@ DELETE /admin/tasks/:id
"name": "Morning News Digest",
"description": "...",
"scope": "personal|team|global",
- "task_type": "prompt|workflow|action",
+ "task_type": "prompt|workflow|action|system",
+ "system_function": "session_cleanup",
"persona_id": "uuid|null",
"model_id": "claude-sonnet-4-20250514",
"system_prompt": "...",
diff --git a/docs/ICD/workspaces.md b/docs/ICD/workspaces.md
index 945d9d5..c414173 100644
--- a/docs/ICD/workspaces.md
+++ b/docs/ICD/workspaces.md
@@ -361,13 +361,15 @@ Independent of workspace scope — credentials are user-level.
```
POST /git-credentials ← { "name", "auth_type", ... }
+POST /git-credentials/generate ← { "name", "persona_id?" }
GET /git-credentials → { "data": [GitCredentialSummary] }
+GET /git-credentials/:id/public-key → { "public_key", "fingerprint" }
DELETE /git-credentials/:id
```
**Auth:** Authenticated user. Credentials are scoped to the requesting user.
-**Create request:**
+**Create request (manual):**
`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).
+**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:**
```json
@@ -388,12 +421,18 @@ Returns `201` with the credential summary (never exposes encrypted data).
"id": "uuid",
"name": "GitHub PAT",
"auth_type": "https_pat",
+ "public_key": "",
+ "fingerprint": "",
+ "persona_id": null,
"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
not found or not owned by the requesting user.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index e26ad1a..c0c9eb5 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -30,9 +30,9 @@ v0.9.x–v0.27.5 Foundation → Extensions → Surfaces → Auth ✅
├─ v0.28.5 Frontend SDK + Pipes ✅
│ (switchboard-sdk.js, pipe/filter
│ pipeline, component mounting)
- ├─ v0.28.6 Infrastructure
- │ (virtual scroll, Helm, task webhook
- │ UI, system tasks, model prefs)
+ ├─ v0.28.6 Infrastructure ✅
+ │ (virtual scroll, Helm, system tasks,
+ │ broadcast, git keygen, model prefs)
└─ v0.28.7 Unified Packaging + Task RBAC
(.pkg archive, manifest.type,
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,
scoped/unscoped execution
-### v0.28.6 — Infrastructure
-- [ ] Virtual scroll for long conversations (prerequisite for heavy task output channels)
-- [ ] KB auto-injection: platform-registered pre-send pipe filter (`_kb-auto-inject`,
- priority 5), top-K chunk prepend to `ctx.metadata.kb_context`, context budget aware,
- per-channel toggle. First real validation of the v0.28.5 pipeline architecture.
-- [ ] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`)
-- [ ] Per-provider model preferences — finalize: make `provider_config_id` required on
+### v0.28.6 — Infrastructure ✅
+- [x] Virtual scroll for long conversations (prerequisite for heavy task output channels)
+- [x] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`)
+- [x] Per-provider model preferences — finalize: make `provider_config_id` required on
`PUT /models/preferences` (fixes NULL-in-UNIQUE dedup bug), migrate `/models/enabled`
and `/models/preferences` to `{"data": [...]}` envelope, add integration test coverage
-- [ ] Git credentials settings UI: vault-encrypted per-user credentials, SSH key
- management, per-workspace remote config. Table exists (`git_credentials`), vault
- pattern exists — needs settings section and CRUD handler.
-- [ ] `system.announcement` notification type: admin broadcast endpoint
+- [x] Git credentials settings UI: server-side ED25519 key generation (private key
+ vault-encrypted, public key exposed for git host), optional persona binding for
+ commit attribution, settings section with generate/list/copy/delete
+- [x] `system.announcement` notification type: admin broadcast endpoint
(`POST /admin/notifications/broadcast`), fan-out to all active users via
`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`
fields in create/edit form, task-to-task chaining documentation in admin UI.
Backend fully implemented — this is pure admin UI work.
-- [ ] System task type: `task_type: "system"` — built-in Go function registry
- (`retention_sweep`, `memory_compact`, `session_cleanup`, `staleness_check`).
+- [x] System task type: `task_type: "system"` — built-in Go function registry
+ (`retention_sweep`, `health_prune`, `session_cleanup`, `staleness_check`).
Admin creates task, picks function from dropdown, sets cron schedule. Executor
calls registered Go function instead of LLM completion. Replaces the current
goroutine-based background jobs with visible, configurable, auditable tasks.
@@ -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).
Core platform ops must not break from bad user code. Admin-only by design
(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
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,
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
diff --git a/server/database/migrations/005_channels.sql b/server/database/migrations/005_channels.sql
index 80c29a5..91f5eb3 100644
--- a/server/database/migrations/005_channels.sql
+++ b/server/database/migrations/005_channels.sql
@@ -217,7 +217,7 @@ CREATE TABLE IF NOT EXISTS user_model_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
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,
preferred_temperature FLOAT,
preferred_max_tokens INT,
diff --git a/server/database/migrations/009_workspaces.sql b/server/database/migrations/009_workspaces.sql
index cac440f..df003f8 100644
--- a/server/database/migrations/009_workspaces.sql
+++ b/server/database/migrations/009_workspaces.sql
@@ -107,6 +107,9 @@ CREATE TABLE IF NOT EXISTS git_credentials (
auth_type TEXT NOT NULL DEFAULT 'https_pat',
encrypted_data 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()
);
diff --git a/server/database/migrations/019_tasks.sql b/server/database/migrations/019_tasks.sql
index 3cf44e0..409e852 100644
--- a/server/database/migrations/019_tasks.sql
+++ b/server/database/migrations/019_tasks.sql
@@ -21,7 +21,8 @@ CREATE TABLE IF NOT EXISTS tasks (
-- What to run
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,
model_id TEXT,
system_prompt TEXT DEFAULT '',
diff --git a/server/database/migrations/sqlite/005_channels.sql b/server/database/migrations/sqlite/005_channels.sql
index 6b55cce..051e2a5 100644
--- a/server/database/migrations/sqlite/005_channels.sql
+++ b/server/database/migrations/sqlite/005_channels.sql
@@ -133,7 +133,7 @@ CREATE TABLE IF NOT EXISTS user_model_settings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
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,
preferred_temperature REAL,
preferred_max_tokens INTEGER,
diff --git a/server/database/migrations/sqlite/009_workspaces.sql b/server/database/migrations/sqlite/009_workspaces.sql
index ddabf6b..2c1c8d1 100644
--- a/server/database/migrations/sqlite/009_workspaces.sql
+++ b/server/database/migrations/sqlite/009_workspaces.sql
@@ -60,6 +60,9 @@ CREATE TABLE IF NOT EXISTS git_credentials (
auth_type TEXT NOT NULL DEFAULT 'https_pat',
encrypted_data 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'))
);
diff --git a/server/database/migrations/sqlite/019_tasks.sql b/server/database/migrations/sqlite/019_tasks.sql
index de405ea..eda98e7 100644
--- a/server/database/migrations/sqlite/019_tasks.sql
+++ b/server/database/migrations/sqlite/019_tasks.sql
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS tasks (
description TEXT DEFAULT '',
scope TEXT NOT NULL DEFAULT 'personal',
task_type TEXT NOT NULL DEFAULT 'prompt',
+ system_function TEXT DEFAULT '',
persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
model_id TEXT,
system_prompt TEXT DEFAULT '',
diff --git a/server/handlers/git.go b/server/handlers/git.go
index 413162d..6b91fc1 100644
--- a/server/handlers/git.go
+++ b/server/handlers/git.go
@@ -1,11 +1,16 @@
package handlers
import (
+ "crypto/ed25519"
+ "crypto/rand"
"encoding/json"
+ "encoding/pem"
"fmt"
"net/http"
"strconv"
+ "golang.org/x/crypto/ssh"
+
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/models"
"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})
}
+
+// 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,
+ })
+}
diff --git a/server/handlers/git_credentials_test.go b/server/handlers/git_credentials_test.go
new file mode 100644
index 0000000..33ef820
--- /dev/null
+++ b/server/handlers/git_credentials_test.go
@@ -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)
+ }
+}
diff --git a/server/handlers/model_prefs_test.go b/server/handlers/model_prefs_test.go
new file mode 100644
index 0000000..db42251
--- /dev/null
+++ b/server/handlers/model_prefs_test.go
@@ -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)
+ }
+}
diff --git a/server/handlers/notification_test.go b/server/handlers/notification_test.go
index 026fd19..515c8e9 100644
--- a/server/handlers/notification_test.go
+++ b/server/handlers/notification_test.go
@@ -12,6 +12,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/models"
+ "git.gobha.me/xcaliber/chat-switchboard/notifications"
"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"
@@ -61,6 +62,16 @@ func setupNotifHarness(t *testing.T) *notifHarness {
protected.PUT("/notifications/preferences/:type", notifH.SetPreference)
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
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)
@@ -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 ────────────────────────────────
func keys(m map[string]json.RawMessage) []string {
diff --git a/server/handlers/notifications.go b/server/handlers/notifications.go
index 9d6d77a..de4dd8a 100644
--- a/server/handlers/notifications.go
+++ b/server/handlers/notifications.go
@@ -3,6 +3,7 @@ package handlers
import (
"database/sql"
"encoding/json"
+ "log"
"net/http"
"strconv"
"time"
@@ -11,6 +12,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
+ "git.gobha.me/xcaliber/chat-switchboard/notifications"
"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})
}
+
+// ── 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)})
+}
diff --git a/server/handlers/tasks.go b/server/handlers/tasks.go
index 0c9b393..ef5cc01 100644
--- a/server/handlers/tasks.go
+++ b/server/handlers/tasks.go
@@ -141,6 +141,22 @@ func (h *TaskHandler) Create(c *gin.Context) {
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
if t.Name == "" {
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,
})
}
+
+// 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()})
+}
diff --git a/server/main.go b/server/main.go
index 8a49f27..3fae41f 100644
--- a/server/main.go
+++ b/server/main.go
@@ -408,6 +408,9 @@ func main() {
// v0.27.2: Task scheduler with executor — needs hub + notification service
if stores.Tasks != nil {
+ // v0.28.6: Register built-in system task functions
+ scheduler.RegisterBuiltins()
+
exec := scheduler.NewExecutor(stores, keyResolver, hub, healthAccum)
taskSched := scheduler.New(stores, exec)
go taskSched.Run()
@@ -813,7 +816,9 @@ func main() {
// Git credentials (v0.21.4) — user-scoped, independent of workspace
gitCredH := handlers.NewGitCredentialHandler(stores, keyResolver)
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)
// Files (upload/download)
@@ -1017,6 +1022,10 @@ func main() {
admin.PUT("/teams/:id/members/:memberId", teamAdm.UpdateMember)
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
admin.GET("/audit", adm.ListAuditLog)
admin.GET("/audit/actions", adm.ListAuditActions)
@@ -1134,6 +1143,7 @@ func main() {
admin.POST("/tasks/:id/run", taskAdm.RunNow)
admin.POST("/tasks/:id/kill", taskAdm.KillRun)
admin.DELETE("/tasks/:id", taskAdm.Delete)
+ admin.GET("/system-functions", taskAdm.ListSystemFunctions)
}
}
diff --git a/server/models/models_git.go b/server/models/models_git.go
index efaabe4..de897b9 100644
--- a/server/models/models_git.go
+++ b/server/models/models_git.go
@@ -13,24 +13,33 @@ type GitCredential struct {
AuthType string `json:"auth_type" db:"auth_type"` // https_pat, https_basic, ssh_key
EncryptedData []byte `json:"-" db:"encrypted_data"` // 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"`
}
// GitCredentialSummary is the safe-to-expose version (no encrypted data).
type GitCredentialSummary struct {
- ID string `json:"id"`
- Name string `json:"name"`
- AuthType string `json:"auth_type"`
- CreatedAt time.Time `json:"created_at"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ AuthType string `json:"auth_type"`
+ 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.
func (c *GitCredential) Summary() GitCredentialSummary {
return GitCredentialSummary{
- ID: c.ID,
- Name: c.Name,
- AuthType: c.AuthType,
- CreatedAt: c.CreatedAt,
+ ID: c.ID,
+ Name: c.Name,
+ AuthType: c.AuthType,
+ PublicKey: c.PublicKey,
+ Fingerprint: c.Fingerprint,
+ PersonaID: c.PersonaID,
+ CreatedAt: c.CreatedAt,
}
}
diff --git a/server/models/models_notification.go b/server/models/models_notification.go
index 7207505..d119962 100644
--- a/server/models/models_notification.go
+++ b/server/models/models_notification.go
@@ -34,6 +34,7 @@ const (
NotifTypeTaskCompleted = "task.completed"
NotifTypeTaskFailed = "task.failed"
NotifTypeTaskBudget = "task.budget_exceeded"
+ NotifTypeAnnouncement = "system.announcement"
)
// Resource type constants for click-to-navigate.
diff --git a/server/models/models_task.go b/server/models/models_task.go
index af0fca4..7cc1dc4 100644
--- a/server/models/models_task.go
+++ b/server/models/models_task.go
@@ -7,7 +7,8 @@ import (
// Task is a scheduled, one-shot, or webhook-triggered job that creates 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 {
ID string `json:"id" db:"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
// 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"`
ModelID string `json:"model_id" db:"model_id"`
SystemPrompt string `json:"system_prompt" db:"system_prompt"`
diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html
index 6720296..2faab9d 100644
--- a/server/pages/templates/base.html
+++ b/server/pages/templates/base.html
@@ -103,6 +103,7 @@
+
{{/* v0.25.0: Component scripts — available on all surfaces */}}
diff --git a/server/pages/templates/surfaces/admin.html b/server/pages/templates/surfaces/admin.html
index e0d8ada..e36f715 100644
--- a/server/pages/templates/surfaces/admin.html
+++ b/server/pages/templates/surfaces/admin.html
@@ -36,7 +36,7 @@
Routing
-
+
System
@@ -220,6 +220,7 @@
+
+
@@ -256,6 +258,7 @@
teams: () => typeof UI !== 'undefined' && UI.loadTeamsSettings?.(),
workflows: () => typeof loadTeamWorkflows === 'function' && loadTeamWorkflows(),
tasks: () => typeof _loadSettingsTasks === 'function' && _loadSettingsTasks(),
+ gitkeys: () => typeof _loadSettingsGitKeys === 'function' && _loadSettingsGitKeys(),
};
// Populate App.policies and App.models from API.
diff --git a/server/scheduler/executor.go b/server/scheduler/executor.go
index c743c7b..f8599c1 100644
--- a/server/scheduler/executor.go
+++ b/server/scheduler/executor.go
@@ -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) {
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.
if task.TaskType == "action" {
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.
// Skips provider resolution and completion entirely.
func (e *Executor) executeAction(ctx context.Context, task models.Task, run *models.TaskRun, channelID string, startTime time.Time) {
diff --git a/server/scheduler/system_builtins.go b/server/scheduler/system_builtins.go
new file mode 100644
index 0000000..c2b5dc6
--- /dev/null
+++ b/server/scheduler/system_builtins.go
@@ -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
+}
diff --git a/server/store/interfaces.go b/server/store/interfaces.go
index d7401ca..d64c31d 100644
--- a/server/store/interfaces.go
+++ b/server/store/interfaces.go
@@ -183,6 +183,7 @@ type UserStore interface {
List(ctx context.Context, opts ListOptions) ([]models.User, int, error)
UpdateLastLogin(ctx context.Context, id string) error
SetActive(ctx context.Context, id string, active bool) error
+ ListActiveUserIDs(ctx context.Context) ([]string, error)
// Refresh tokens
CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error
diff --git a/server/store/postgres/git_credentials.go b/server/store/postgres/git_credentials.go
index 7158783..4d569bc 100644
--- a/server/store/postgres/git_credentials.go
+++ b/server/store/postgres/git_credentials.go
@@ -13,29 +13,32 @@ type GitCredentialStore struct{}
func (s *GitCredentialStore) Create(ctx context.Context, cred *models.GitCredential) error {
if cred.ID != "" {
return DB.QueryRowContext(ctx, `
- INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce)
- VALUES ($1, $2, $3, $4, $5, $6)
+ 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, $7, $8, $9)
RETURNING created_at`,
cred.ID, cred.UserID, cred.Name, cred.AuthType,
cred.EncryptedData, cred.Nonce,
+ cred.PublicKey, cred.Fingerprint, cred.PersonaID,
).Scan(&cred.CreatedAt)
}
return DB.QueryRowContext(ctx, `
- INSERT INTO git_credentials (user_id, name, auth_type, encrypted_data, nonce)
- VALUES ($1, $2, $3, $4, $5)
+ INSERT INTO git_credentials (user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, created_at`,
cred.UserID, cred.Name, cred.AuthType,
cred.EncryptedData, cred.Nonce,
+ cred.PublicKey, cred.Fingerprint, cred.PersonaID,
).Scan(&cred.ID, &cred.CreatedAt)
}
func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.GitCredential, error) {
var c models.GitCredential
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,
).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 {
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) {
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)
if err != nil {
return nil, err
@@ -55,7 +58,8 @@ func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]m
for rows.Next() {
var c models.GitCredential
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
}
creds = append(creds, c)
diff --git a/server/store/postgres/tasks.go b/server/store/postgres/tasks.go
index 9000ed5..2e4d6c6 100644
--- a/server/store/postgres/tasks.go
+++ b/server/store/postgres/tasks.go
@@ -23,7 +23,8 @@ func NewTaskStore() *TaskStore { return &TaskStore{} }
// (string, json.RawMessage). database/sql returns "unsupported Scan,
// storing driver.Value type " for these without COALESCE.
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,
schedule, timezone, is_active,
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 {
return scanner.Scan(
&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.TriggerToken,
&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)
return DB.QueryRowContext(ctx, `
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,
trigger_token,
max_tokens, max_tool_calls, max_wall_clock, output_mode,
output_channel_id, webhook_url, webhook_secret, provider_config_id,
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`,
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,
nilIfEmpty(t.TriggerToken),
t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode,
diff --git a/server/store/postgres/user.go b/server/store/postgres/user.go
index 18d9d5d..3b5e024 100644
--- a/server/store/postgres/user.go
+++ b/server/store/postgres/user.go
@@ -122,6 +122,23 @@ func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error
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 ──────────────────────────
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
diff --git a/server/store/sqlite/git_credentials.go b/server/store/sqlite/git_credentials.go
index 184582d..68243c2 100644
--- a/server/store/sqlite/git_credentials.go
+++ b/server/store/sqlite/git_credentials.go
@@ -18,11 +18,12 @@ func (s *GitCredentialStore) Create(ctx context.Context, cred *models.GitCredent
cred.ID = uuid.NewString()
}
_, err := DB.ExecContext(ctx, `
- INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce, created_at)
- VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`,
+ INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
cred.ID, cred.UserID, cred.Name, cred.AuthType,
base64.StdEncoding.EncodeToString(cred.EncryptedData),
base64.StdEncoding.EncodeToString(cred.Nonce),
+ cred.PublicKey, cred.Fingerprint, cred.PersonaID,
)
if err != nil {
return err
@@ -37,10 +38,10 @@ func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.Gi
var c models.GitCredential
var encData, nonce string
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,
).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 {
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) {
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)
if err != nil {
return nil, err
@@ -63,7 +64,7 @@ func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]m
var c models.GitCredential
var encData, nonce string
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
}
c.EncryptedData, _ = base64.StdEncoding.DecodeString(encData)
diff --git a/server/store/sqlite/tasks.go b/server/store/sqlite/tasks.go
index f09c37e..f199ef7 100644
--- a/server/store/sqlite/tasks.go
+++ b/server/store/sqlite/tasks.go
@@ -19,7 +19,8 @@ func NewTaskStore() *TaskStore { return &TaskStore{} }
// COALESCE wraps nullable columns that scan into non-pointer Go types.
// tool_grants uses COALESCE to '[]' so the string intermediate never sees NULL.
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,
schedule, timezone, is_active,
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
err := scanner.Scan(
&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.TriggerToken,
&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
_, err := DB.ExecContext(ctx, `
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,
trigger_token,
max_tokens, max_tool_calls, max_wall_clock, output_mode,
output_channel_id, webhook_url, webhook_secret, provider_config_id,
notify_on_complete, notify_on_failure, next_run_at)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
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),
nilIfEmpty(t.TriggerToken),
t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode,
diff --git a/server/store/sqlite/user.go b/server/store/sqlite/user.go
index dc8f805..48e6bd0 100644
--- a/server/store/sqlite/user.go
+++ b/server/store/sqlite/user.go
@@ -128,6 +128,23 @@ func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error
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 ──────────────────────────
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
diff --git a/server/taskutil/system_registry.go b/server/taskutil/system_registry.go
new file mode 100644
index 0000000..b1ad22f
--- /dev/null
+++ b/server/taskutil/system_registry.go
@@ -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
+}
diff --git a/src/js/broadcast-admin.js b/src/js/broadcast-admin.js
new file mode 100644
index 0000000..18cbecc
--- /dev/null
+++ b/src/js/broadcast-admin.js
@@ -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 =
+ '
' +
+ '
' +
+ 'Send a system announcement to all active users. Notifications appear in their ' +
+ 'bell menu and optionally via email (based on user preferences).' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '' +
+ '' +
+ '
';
+
+ 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 =
+ '✓ ' +
+ '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 =
+ '✗ ' +
+ 'Failed: ' + (err.message || err);
+ UI.toast('Broadcast failed', 'error');
+ } finally {
+ sendBtn.disabled = false;
+ sendBtn.textContent = ' Send Broadcast';
+ }
+ });
+ });
diff --git a/src/js/chat.js b/src/js/chat.js
index 3c316b6..65a98ac 100644
--- a/src/js/chat.js
+++ b/src/js/chat.js
@@ -236,6 +236,8 @@ async function loadChats() {
async function selectChat(chatId) {
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);
App.setActive(chatId, chat?.type || 'direct');
try { sessionStorage.setItem('cs-active-conversation', JSON.stringify(App.activeConversation)); } catch (_) {}
@@ -418,6 +420,7 @@ function _cleanStaleChatModel(chatId) {
async function newChat() {
clearStaged(); // Discard any staged files
+ if (typeof VirtualScroll !== 'undefined') VirtualScroll.destroy();
App.setActive(null);
UI.renderChatList();
UI.renderChannelsSection();
diff --git a/src/js/git-credentials-ui.js b/src/js/git-credentials-ui.js
new file mode 100644
index 0000000..8aaa3e3
--- /dev/null
+++ b/src/js/git-credentials-ui.js
@@ -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 =
+ '
' +
+ '
' +
+ '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.' +
+ '
' +
+ '
' +
+ '
SSH Keys
' +
+ '' +
+ '
' +
+ '' +
+ '
' +
+ 'No SSH keys yet. Generate one to get started.' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ 'Security' +
+ '
' +
+ '
' +
+ '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.' +
+ '
Add this key to your git host (GitHub → Settings → SSH Keys):
' +
+ '' +
+ '
Fingerprint: ' + esc(cred.fingerprint) + '
',
+ [{ 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);
diff --git a/src/js/task-admin.js b/src/js/task-admin.js
index 033185d..d79dbfa 100644
--- a/src/js/task-admin.js
+++ b/src/js/task-admin.js
@@ -9,6 +9,7 @@
'
' +
'' +
'' +
+ '' +
'
' +
'
' +
'
' +
@@ -47,6 +48,28 @@
'' +
'
' +
'' +
+ '
' +
+ '
' +
+ '
Create System Task
' +
+ '
' +
+ 'System tasks run built-in Go functions on a schedule. No LLM, no provider — ' +
+ 'pure platform operations (cleanup, sweeps, compaction). Admin-only.
Used to sign outbound webhook payloads. Leave blank for auto-generated secret.
' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ ' Webhook-triggered task
' +
+ '
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.
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.
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
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.
',
+ [{ 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();
- // Refresh sidebar
if (typeof TaskSidebar !== 'undefined') TaskSidebar.refresh();
} catch (err) {
UI.toast(err.message || 'Failed to create task', 'error');
diff --git a/src/js/ui-admin.js b/src/js/ui-admin.js
index fff1413..fd6b7cb 100644
--- a/src/js/ui-admin.js
+++ b/src/js/ui-admin.js
@@ -11,7 +11,7 @@ const ADMIN_SECTIONS = {
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
workflows: ['workflows', 'tasks'],
routing: ['health', 'routing', 'capabilities'],
- system: ['settings', 'storage', 'extensions', 'channels', 'surfaces'],
+ system: ['settings', 'storage', 'extensions', 'channels', 'surfaces', 'broadcast'],
monitoring: ['usage', 'audit', 'stats'],
};
@@ -20,7 +20,7 @@ const ADMIN_LABELS = {
providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
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',
};
@@ -54,6 +54,7 @@ const ADMIN_LOADERS = {
channels: () => UI.loadAdminChannels(),
workflows: () => typeof _loadAdminWorkflows === 'function' ? _loadAdminWorkflows() : null,
tasks: () => typeof _loadAdminTasks === 'function' ? _loadAdminTasks() : null,
+ broadcast: () => typeof _loadAdminBroadcast === 'function' ? _loadAdminBroadcast() : null,
};
// Find which category a section belongs to
diff --git a/src/js/ui-core.js b/src/js/ui-core.js
index 27a8852..bb67ec3 100644
--- a/src/js/ui-core.js
+++ b/src/js/ui-core.js
@@ -709,6 +709,7 @@ const UI = {
renderMessages(messages) {
const el = document.getElementById('chatMessages');
if (!messages || messages.length === 0) {
+ if (typeof VirtualScroll !== 'undefined') VirtualScroll.destroy();
this.showEmptyState();
return;
}
@@ -719,7 +720,30 @@ const UI = {
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 = '';
if (summaryIdx >= 0) {
const hiddenCount = messages.filter((m, i) => i < summaryIdx && m.role !== 'system').length;
diff --git a/src/js/virtual-scroll.js b/src/js/virtual-scroll.js
new file mode 100644
index 0000000..4c3225d
--- /dev/null
+++ b/src/js/virtual-scroll.js
@@ -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 += '';
+
+ // "Load more" indicator
+ if (start > 0) {
+ html += '