Changeset 0.28.6 (#192)
This commit is contained in:
78
CHANGELOG.md
78
CHANGELOG.md
@@ -1,5 +1,83 @@
|
||||
# Changelog
|
||||
|
||||
## [0.28.6] — 2026-03-15
|
||||
|
||||
### Summary
|
||||
|
||||
Infrastructure release. Virtual scroll for long conversations, Helm chart
|
||||
for Kubernetes deployment, system task type (Go function registry replacing
|
||||
ad-hoc goroutines), admin broadcast notifications, server-side SSH key
|
||||
generation for git credentials, task webhook trigger UI, and model
|
||||
preference dedup fix.
|
||||
|
||||
KB auto-inject pipe filter deferred to v0.29.0 — will be built as the
|
||||
reference Go implementation of the server-side filter model alongside
|
||||
Starlark, ensuring the architecture is right and performant before
|
||||
user code runs in it.
|
||||
|
||||
### New
|
||||
|
||||
- **Virtual scroll** — viewport-windowed message rendering for long
|
||||
conversations (100+ messages). IntersectionObserver-based sentinel
|
||||
triggers prepend chunks of 40 as user scrolls up. DOM capped at ~80
|
||||
nodes regardless of conversation length. Streaming messages always
|
||||
rendered. Transparent fallback for short conversations.
|
||||
- **Helm chart** — `chart/` directory with full Kubernetes deployment.
|
||||
`helm install switchboard ./chart` replaces raw manifests. Configures
|
||||
backend + frontend deployments, services, ingress (Traefik), PVC,
|
||||
ConfigMap, Secret. All `config.go` env vars exposed as values.
|
||||
- **System task type** — `task_type: "system"` with Go function registry.
|
||||
Built-in functions: `session_cleanup`, `staleness_check`,
|
||||
`retention_sweep`, `health_prune`. Admin creates task, picks function
|
||||
from dropdown, sets cron schedule. Executor calls registered Go function
|
||||
instead of LLM. Replaces ad-hoc goroutine background jobs with visible,
|
||||
configurable, auditable tasks. Permanent track — not replaced by Starlark.
|
||||
`GET /admin/system-functions` returns available functions.
|
||||
- **Admin broadcast** — `POST /admin/notifications/broadcast` sends
|
||||
`system.announcement` notification to all active users via `NotifyMany`.
|
||||
Emits `system.broadcast` WebSocket event. Admin UI compose form with
|
||||
title, message, level selector (info/warning/critical).
|
||||
- **Git credential key generation** — `POST /git-credentials/generate`
|
||||
creates ED25519 SSH keypair server-side. Private key vault-encrypted,
|
||||
never exposed. Public key + SHA256 fingerprint returned for user to add
|
||||
to git host. Optional `persona_id` for per-persona commit attribution.
|
||||
`GET /git-credentials/:id/public-key` for clipboard copy. Settings UI
|
||||
section with generate, list, copy, delete.
|
||||
- **Task webhook trigger UI** — schedule selector gains "Webhook trigger"
|
||||
option. Trigger URL displayed with copy button after creation. Link
|
||||
button on webhook tasks in list view. `webhook_secret` field for HMAC
|
||||
signing. Task-to-task chaining documented (Task A webhook_url → Task B
|
||||
trigger URL).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Model preferences NULL-in-UNIQUE** — `provider_config_id` column on
|
||||
`user_model_settings` changed to `NOT NULL`. The `UNIQUE(user_id,
|
||||
model_id, provider_config_id)` constraint silently allowed duplicates
|
||||
when the column was NULL. Handler already enforced `binding:"required"`
|
||||
— schema now matches.
|
||||
- **ICD runner shape** — `S.modelPreference` gains `provider_config_id`
|
||||
field (smoke tier shape assertion).
|
||||
|
||||
### Changed
|
||||
|
||||
- `ListActiveUserIDs` added to `UserStore` interface (PG + SQLite).
|
||||
- `NotifTypeAnnouncement` constant added to notification types.
|
||||
- Task model gains `system_function` field. Task stores updated
|
||||
(taskColumns, scanTask, Create) in both PG and SQLite.
|
||||
- ICD runner version bumped. New tests: model prefs (8), broadcast (3),
|
||||
git credentials (6), system tasks (4).
|
||||
|
||||
### Docs
|
||||
|
||||
- `notifications.md` — broadcast endpoint, `system.broadcast` WS event.
|
||||
- `admin.md` — broadcast cross-reference.
|
||||
- `workspaces.md` — `/generate`, `/public-key` endpoints, new fields.
|
||||
- `tasks.md` — system task type, function registry, chaining pattern,
|
||||
`GET /admin/system-functions`.
|
||||
|
||||
|
||||
|
||||
## [0.28.5] — 2026-03-14
|
||||
|
||||
### Summary
|
||||
|
||||
11
chart/Chart.yaml
Normal file
11
chart/Chart.yaml
Normal 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
25
chart/templates/NOTES.txt
Normal 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 }}
|
||||
64
chart/templates/_helpers.tpl
Normal file
64
chart/templates/_helpers.tpl
Normal 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 }}
|
||||
49
chart/templates/configmap.yaml
Normal file
49
chart/templates/configmap.yaml
Normal 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 }}
|
||||
65
chart/templates/deployment-backend.yaml
Normal file
65
chart/templates/deployment-backend.yaml
Normal 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 }}
|
||||
50
chart/templates/deployment-frontend.yaml
Normal file
50
chart/templates/deployment-frontend.yaml
Normal 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
|
||||
51
chart/templates/ingress.yaml
Normal file
51
chart/templates/ingress.yaml
Normal 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
17
chart/templates/pvc.yaml
Normal 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 }}
|
||||
31
chart/templates/secret.yaml
Normal file
31
chart/templates/secret.yaml
Normal 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 }}
|
||||
35
chart/templates/services.yaml
Normal file
35
chart/templates/services.yaml
Normal 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
135
chart/values.yaml
Normal 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: []
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": "...",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ v0.9.x–v0.27.5 Foundation → Extensions → Surfaces → Auth ✅
|
||||
├─ v0.28.5 Frontend SDK + Pipes ✅
|
||||
│ (switchboard-sdk.js, pipe/filter
|
||||
│ pipeline, component mounting)
|
||||
├─ v0.28.6 Infrastructure
|
||||
│ (virtual scroll, Helm, task webhook
|
||||
│ UI, system tasks, model prefs)
|
||||
├─ v0.28.6 Infrastructure ✅
|
||||
│ (virtual scroll, Helm, system tasks,
|
||||
│ broadcast, git keygen, model prefs)
|
||||
└─ v0.28.7 Unified Packaging + Task RBAC
|
||||
(.pkg archive, manifest.type,
|
||||
task permission gate pre-Starlark)
|
||||
@@ -295,27 +295,24 @@ Pipeline wires filters in priority order; any filter can halt the chain.
|
||||
- [x] Pipe filter tests: priority ordering, halt semantics, error isolation,
|
||||
scoped/unscoped execution
|
||||
|
||||
### v0.28.6 — Infrastructure
|
||||
- [ ] Virtual scroll for long conversations (prerequisite for heavy task output channels)
|
||||
- [ ] KB auto-injection: platform-registered pre-send pipe filter (`_kb-auto-inject`,
|
||||
priority 5), top-K chunk prepend to `ctx.metadata.kb_context`, context budget aware,
|
||||
per-channel toggle. First real validation of the v0.28.5 pipeline architecture.
|
||||
- [ ] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`)
|
||||
- [ ] Per-provider model preferences — finalize: make `provider_config_id` required on
|
||||
### v0.28.6 — Infrastructure ✅
|
||||
- [x] Virtual scroll for long conversations (prerequisite for heavy task output channels)
|
||||
- [x] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`)
|
||||
- [x] Per-provider model preferences — finalize: make `provider_config_id` required on
|
||||
`PUT /models/preferences` (fixes NULL-in-UNIQUE dedup bug), migrate `/models/enabled`
|
||||
and `/models/preferences` to `{"data": [...]}` envelope, add integration test coverage
|
||||
- [ ] Git credentials settings UI: vault-encrypted per-user credentials, SSH key
|
||||
management, per-workspace remote config. Table exists (`git_credentials`), vault
|
||||
pattern exists — needs settings section and CRUD handler.
|
||||
- [ ] `system.announcement` notification type: admin broadcast endpoint
|
||||
- [x] Git credentials settings UI: server-side ED25519 key generation (private key
|
||||
vault-encrypted, public key exposed for git host), optional persona binding for
|
||||
commit attribution, settings section with generate/list/copy/delete
|
||||
- [x] `system.announcement` notification type: admin broadcast endpoint
|
||||
(`POST /admin/notifications/broadcast`), fan-out to all active users via
|
||||
`NotifyMany`, admin UI for composing announcements
|
||||
- [ ] Task webhook trigger UI: schedule selector gains `webhook` option,
|
||||
- [x] Task webhook trigger UI: schedule selector gains `webhook` option,
|
||||
trigger URL displayed + copy button, outbound `webhook_url` + `webhook_secret`
|
||||
fields in create/edit form, task-to-task chaining documentation in admin UI.
|
||||
Backend fully implemented — this is pure admin UI work.
|
||||
- [ ] System task type: `task_type: "system"` — built-in Go function registry
|
||||
(`retention_sweep`, `memory_compact`, `session_cleanup`, `staleness_check`).
|
||||
- [x] System task type: `task_type: "system"` — built-in Go function registry
|
||||
(`retention_sweep`, `health_prune`, `session_cleanup`, `staleness_check`).
|
||||
Admin creates task, picks function from dropdown, sets cron schedule. Executor
|
||||
calls registered Go function instead of LLM completion. Replaces the current
|
||||
goroutine-based background jobs with visible, configurable, auditable tasks.
|
||||
@@ -323,6 +320,9 @@ Pipeline wires filters in priority order; any filter can halt the chain.
|
||||
**Permanent track** — Go registry is not replaced by Starlark (v0.29.0).
|
||||
Core platform ops must not break from bad user code. Admin-only by design
|
||||
(no RBAC needed — hardcoded to admin role).
|
||||
- KB auto-injection moved to v0.29.0 — built as the reference Go implementation
|
||||
of the server-side filter model alongside Starlark. Ensures the filter
|
||||
architecture is right and performant before user code runs in it.
|
||||
|
||||
### v0.28.7 — Unified Packaging + Task RBAC
|
||||
Single `.pkg` archive format for both surfaces and extensions. Manifest
|
||||
@@ -453,6 +453,12 @@ extension infrastructure (v0.11.0).
|
||||
data quality checks, integration sync, cleanup scripts. Editable,
|
||||
versionable, sandbox-isolated, permission-gated.
|
||||
|
||||
- [ ] KB auto-injection: server-side pre-completion filter (Go built-in,
|
||||
not Starlark). Top-K chunk prepend from channel-bound KBs, context
|
||||
budget aware, per-channel toggle. Reference implementation of the
|
||||
server-side filter model that Starlark filters will mirror. Moved
|
||||
from v0.28.6 to validate alongside the Starlark filter architecture.
|
||||
|
||||
---
|
||||
|
||||
## v0.29.1 — API Extensions
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
|
||||
@@ -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 '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'))
|
||||
);
|
||||
|
||||
|
||||
@@ -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 '',
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
281
server/handlers/git_credentials_test.go
Normal file
281
server/handlers/git_credentials_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
363
server/handlers/model_prefs_test.go
Normal file
363
server/handlers/model_prefs_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)})
|
||||
}
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 */}}
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
130
server/scheduler/system_builtins.go
Normal file
130
server/scheduler/system_builtins.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
89
server/taskutil/system_registry.go
Normal file
89
server/taskutil/system_registry.go
Normal 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
96
src/js/broadcast-admin.js
Normal 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';
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
180
src/js/git-credentials-ui.js
Normal file
180
src/js/git-credentials-ui.js
Normal 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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
277
src/js/virtual-scroll.js
Normal 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);
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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' };
|
||||
|
||||
Reference in New Issue
Block a user