Changeset 0.28.6 (#192)

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

View File

@@ -1,5 +1,83 @@
# Changelog
## [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

View File

@@ -1 +1 @@
0.28.5
0.28.6

11
chart/Chart.yaml Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

135
chart/values.yaml Normal file
View File

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

View File

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

View File

@@ -91,6 +91,42 @@ POST /admin/notifications/test-email → send test email to requesting ad
Requires admin role. Loads SMTP config from platform settings,
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

View File

@@ -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": "...",

View File

@@ -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.

View File

@@ -30,9 +30,9 @@ v0.9.xv0.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

View File

@@ -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,

View File

@@ -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()
);

View File

@@ -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 '',

View File

@@ -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,

View File

@@ -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'))
);

View File

@@ -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 '',

View File

@@ -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,
})
}

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/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 {

View File

@@ -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)})
}

View File

@@ -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()})
}

View File

@@ -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)
}
}

View File

@@ -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,
}
}

View File

@@ -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.

View File

@@ -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"`

View File

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

View File

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

View File

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

View File

@@ -51,6 +51,12 @@ func NewExecutor(stores store.Stores, vault *crypto.KeyResolver, hub *events.Hub
func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.TaskRun, channelID string) {
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) {

View File

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

View File

@@ -183,6 +183,7 @@ type UserStore interface {
List(ctx context.Context, opts ListOptions) ([]models.User, int, error)
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

View File

@@ -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)

View File

@@ -23,7 +23,8 @@ func NewTaskStore() *TaskStore { return &TaskStore{} }
// (string, json.RawMessage). database/sql returns "unsupported Scan,
// storing driver.Value type <nil>" 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,

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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,

View File

@@ -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 {

View File

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

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

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

View File

@@ -236,6 +236,8 @@ async function loadChats() {
async function selectChat(chatId) {
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();

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ const ADMIN_SECTIONS = {
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
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

View File

@@ -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;

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

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

View File

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

View File

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

View File

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

View File

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