From bffda043db5e8322a13fedb5d75dd109c4caddaf Mon Sep 17 00:00:00 2001 From: xcaliber Date: Sun, 15 Mar 2026 01:33:38 +0000 Subject: [PATCH] Changeset 0.28.6 (#192) --- CHANGELOG.md | 78 ++++ VERSION | 2 +- chart/Chart.yaml | 11 + chart/templates/NOTES.txt | 25 ++ chart/templates/_helpers.tpl | 64 +++ chart/templates/configmap.yaml | 49 +++ chart/templates/deployment-backend.yaml | 65 ++++ chart/templates/deployment-frontend.yaml | 50 +++ chart/templates/ingress.yaml | 51 +++ chart/templates/pvc.yaml | 17 + chart/templates/secret.yaml | 31 ++ chart/templates/services.yaml | 35 ++ chart/values.yaml | 135 +++++++ docs/ICD/admin.md | 12 + docs/ICD/notifications.md | 40 +- docs/ICD/tasks.md | 60 ++- docs/ICD/workspaces.md | 41 +- docs/ROADMAP.md | 40 +- server/database/migrations/005_channels.sql | 2 +- server/database/migrations/009_workspaces.sql | 3 + server/database/migrations/019_tasks.sql | 3 +- .../migrations/sqlite/005_channels.sql | 2 +- .../migrations/sqlite/009_workspaces.sql | 3 + .../database/migrations/sqlite/019_tasks.sql | 1 + server/handlers/git.go | 100 +++++ server/handlers/git_credentials_test.go | 281 ++++++++++++++ server/handlers/model_prefs_test.go | 363 ++++++++++++++++++ server/handlers/notification_test.go | 115 ++++++ server/handlers/notifications.go | 73 ++++ server/handlers/tasks.go | 22 ++ server/main.go | 10 + server/models/models_git.go | 25 +- server/models/models_notification.go | 1 + server/models/models_task.go | 6 +- server/pages/templates/base.html | 1 + server/pages/templates/surfaces/admin.html | 3 +- server/pages/templates/surfaces/settings.html | 3 + server/scheduler/executor.go | 56 +++ server/scheduler/system_builtins.go | 130 +++++++ server/store/interfaces.go | 1 + server/store/postgres/git_credentials.go | 20 +- server/store/postgres/tasks.go | 14 +- server/store/postgres/user.go | 17 + server/store/sqlite/git_credentials.go | 13 +- server/store/sqlite/tasks.go | 14 +- server/store/sqlite/user.go | 17 + server/taskutil/system_registry.go | 89 +++++ src/js/broadcast-admin.js | 96 +++++ src/js/chat.js | 3 + src/js/git-credentials-ui.js | 180 +++++++++ src/js/task-admin.js | 106 ++++- src/js/task-settings.js | 81 +++- src/js/ui-admin.js | 5 +- src/js/ui-core.js | 26 +- src/js/virtual-scroll.js | 277 +++++++++++++ .../icd-test-runner/js/crud/notifications.js | 25 ++ surfaces/icd-test-runner/js/crud/tasks.js | 61 +++ .../icd-test-runner/js/crud/workspaces.js | 43 +++ surfaces/icd-test-runner/js/framework.js | 2 +- 59 files changed, 3022 insertions(+), 77 deletions(-) create mode 100644 chart/Chart.yaml create mode 100644 chart/templates/NOTES.txt create mode 100644 chart/templates/_helpers.tpl create mode 100644 chart/templates/configmap.yaml create mode 100644 chart/templates/deployment-backend.yaml create mode 100644 chart/templates/deployment-frontend.yaml create mode 100644 chart/templates/ingress.yaml create mode 100644 chart/templates/pvc.yaml create mode 100644 chart/templates/secret.yaml create mode 100644 chart/templates/services.yaml create mode 100644 chart/values.yaml create mode 100644 server/handlers/git_credentials_test.go create mode 100644 server/handlers/model_prefs_test.go create mode 100644 server/scheduler/system_builtins.go create mode 100644 server/taskutil/system_registry.go create mode 100644 src/js/broadcast-admin.js create mode 100644 src/js/git-credentials-ui.js create mode 100644 src/js/virtual-scroll.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 432a94a..e5eb969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,83 @@ # Changelog +## [0.28.6] — 2026-03-15 + +### Summary + +Infrastructure release. Virtual scroll for long conversations, Helm chart +for Kubernetes deployment, system task type (Go function registry replacing +ad-hoc goroutines), admin broadcast notifications, server-side SSH key +generation for git credentials, task webhook trigger UI, and model +preference dedup fix. + +KB auto-inject pipe filter deferred to v0.29.0 — will be built as the +reference Go implementation of the server-side filter model alongside +Starlark, ensuring the architecture is right and performant before +user code runs in it. + +### New + +- **Virtual scroll** — viewport-windowed message rendering for long + conversations (100+ messages). IntersectionObserver-based sentinel + triggers prepend chunks of 40 as user scrolls up. DOM capped at ~80 + nodes regardless of conversation length. Streaming messages always + rendered. Transparent fallback for short conversations. +- **Helm chart** — `chart/` directory with full Kubernetes deployment. + `helm install switchboard ./chart` replaces raw manifests. Configures + backend + frontend deployments, services, ingress (Traefik), PVC, + ConfigMap, Secret. All `config.go` env vars exposed as values. +- **System task type** — `task_type: "system"` with Go function registry. + Built-in functions: `session_cleanup`, `staleness_check`, + `retention_sweep`, `health_prune`. Admin creates task, picks function + from dropdown, sets cron schedule. Executor calls registered Go function + instead of LLM. Replaces ad-hoc goroutine background jobs with visible, + configurable, auditable tasks. Permanent track — not replaced by Starlark. + `GET /admin/system-functions` returns available functions. +- **Admin broadcast** — `POST /admin/notifications/broadcast` sends + `system.announcement` notification to all active users via `NotifyMany`. + Emits `system.broadcast` WebSocket event. Admin UI compose form with + title, message, level selector (info/warning/critical). +- **Git credential key generation** — `POST /git-credentials/generate` + creates ED25519 SSH keypair server-side. Private key vault-encrypted, + never exposed. Public key + SHA256 fingerprint returned for user to add + to git host. Optional `persona_id` for per-persona commit attribution. + `GET /git-credentials/:id/public-key` for clipboard copy. Settings UI + section with generate, list, copy, delete. +- **Task webhook trigger UI** — schedule selector gains "Webhook trigger" + option. Trigger URL displayed with copy button after creation. Link + button on webhook tasks in list view. `webhook_secret` field for HMAC + signing. Task-to-task chaining documented (Task A webhook_url → Task B + trigger URL). + +### Fixed + +- **Model preferences NULL-in-UNIQUE** — `provider_config_id` column on + `user_model_settings` changed to `NOT NULL`. The `UNIQUE(user_id, + model_id, provider_config_id)` constraint silently allowed duplicates + when the column was NULL. Handler already enforced `binding:"required"` + — schema now matches. +- **ICD runner shape** — `S.modelPreference` gains `provider_config_id` + field (smoke tier shape assertion). + +### Changed + +- `ListActiveUserIDs` added to `UserStore` interface (PG + SQLite). +- `NotifTypeAnnouncement` constant added to notification types. +- Task model gains `system_function` field. Task stores updated + (taskColumns, scanTask, Create) in both PG and SQLite. +- ICD runner version bumped. New tests: model prefs (8), broadcast (3), + git credentials (6), system tasks (4). + +### Docs + +- `notifications.md` — broadcast endpoint, `system.broadcast` WS event. +- `admin.md` — broadcast cross-reference. +- `workspaces.md` — `/generate`, `/public-key` endpoints, new fields. +- `tasks.md` — system task type, function registry, chaining pattern, + `GET /admin/system-functions`. + + + ## [0.28.5] — 2026-03-14 ### Summary diff --git a/VERSION b/VERSION index 16b6bce..2aa6ed7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.28.5 +0.28.6 diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 0000000..f40e907 --- /dev/null +++ b/chart/Chart.yaml @@ -0,0 +1,11 @@ +apiVersion: v2 +name: switchboard +description: Chat Switchboard — self-hosted enterprise AI chat platform +type: application +version: 0.1.0 +appVersion: "0.28.6" +home: https://gobha.ai +sources: + - https://git.gobha.me/xcaliber/chat-switchboard +maintainers: + - name: xcaliber diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt new file mode 100644 index 0000000..04c688a --- /dev/null +++ b/chart/templates/NOTES.txt @@ -0,0 +1,25 @@ +Chat Switchboard {{ .Chart.AppVersion }} deployed. + +{{- if .Values.ingress.enabled }} +Access the application at: + {{- if .Values.ingress.tls.enabled }} + https://{{ .Values.ingress.host }}{{ .Values.basePath }} + {{- else }} + http://{{ .Values.ingress.host }}{{ .Values.basePath }} + {{- end }} +{{- else }} +Port-forward to access: + kubectl port-forward svc/{{ .Release.Name }}-frontend 3000:{{ .Values.frontend.port }} + open http://localhost:3000{{ .Values.basePath }} +{{- end }} + +{{- if .Values.admin.password }} +Admin credentials set via Helm values. +{{- else }} +⚠ No admin password set. Set admin.password in values or an existing secret. +{{- end }} + +{{- if not .Values.encryptionKey }} +⚠ ENCRYPTION_KEY not set. BYOK provider keys cannot be encrypted. + Set encryptionKey in values for production use. +{{- end }} diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl new file mode 100644 index 0000000..90681d7 --- /dev/null +++ b/chart/templates/_helpers.tpl @@ -0,0 +1,64 @@ +{{/* +Common labels +*/}} +{{- define "switchboard.labels" -}} +app.kubernetes.io/name: {{ .Chart.Name }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }} +{{- end }} + +{{/* +Backend selector labels +*/}} +{{- define "switchboard.backend.labels" -}} +app.kubernetes.io/component: backend +{{ include "switchboard.labels" . }} +{{- end }} + +{{/* +Frontend selector labels +*/}} +{{- define "switchboard.frontend.labels" -}} +app.kubernetes.io/component: frontend +{{ include "switchboard.labels" . }} +{{- end }} + +{{/* +Secret name +*/}} +{{- define "switchboard.secretName" -}} +{{- if .Values.existingSecret -}} +{{ .Values.existingSecret }} +{{- else -}} +{{ .Release.Name }}-secrets +{{- end -}} +{{- end }} + +{{/* +Backend image +*/}} +{{- define "switchboard.backend.image" -}} +{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag | default .Chart.AppVersion }} +{{- end }} + +{{/* +Frontend image +*/}} +{{- define "switchboard.frontend.image" -}} +{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }} +{{- end }} + +{{/* +Database URL — assembled from postgres.* if url is empty +*/}} +{{- define "switchboard.databaseURL" -}} +{{- if .Values.database.url -}} +{{ .Values.database.url }} +{{- else if eq .Values.database.driver "sqlite" -}} +{{ .Values.database.sqlitePath }} +{{- else -}} +postgres://{{ .Values.database.postgres.user }}:$(POSTGRES_PASSWORD)@{{ .Values.database.postgres.host }}:{{ .Values.database.postgres.port }}/{{ .Values.database.postgres.database }}?sslmode={{ .Values.database.postgres.sslmode }} +{{- end -}} +{{- end }} diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml new file mode 100644 index 0000000..026241c --- /dev/null +++ b/chart/templates/configmap.yaml @@ -0,0 +1,49 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-config + labels: + {{- include "switchboard.labels" . | nindent 4 }} +data: + PORT: {{ .Values.backend.port | quote }} + BASE_PATH: {{ .Values.basePath | quote }} + ENVIRONMENT: {{ .Values.environment | quote }} + DB_DRIVER: {{ .Values.database.driver | quote }} + AUTH_MODE: {{ .Values.auth.mode | quote }} + STORAGE_BACKEND: {{ .Values.storage.backend | quote }} + STORAGE_PATH: {{ .Values.storage.path | quote }} + SESSION_EXPIRY_DAYS: {{ .Values.sessionExpiryDays | quote }} + WORKFLOW_STALE_HOURS: {{ .Values.workflowStaleHours | quote }} + PROVIDER_AUTO_DISABLE_THRESHOLD: {{ .Values.providerAutoDisableThreshold | quote }} + EXTRACTION_MODE: {{ .Values.extraction.mode | quote }} + EXTRACTION_CONCURRENCY: {{ .Values.extraction.concurrency | quote }} + WORKSPACE_INDEXING_ENABLED: {{ .Values.workspace.indexingEnabled | quote }} + WORKSPACE_INDEX_CONCURRENCY: {{ .Values.workspace.indexConcurrency | quote }} + CORS_ALLOWED_ORIGINS: {{ .Values.corsAllowedOrigins | quote }} + {{- if .Values.storage.s3.endpoint }} + S3_ENDPOINT: {{ .Values.storage.s3.endpoint | quote }} + S3_BUCKET: {{ .Values.storage.s3.bucket | quote }} + S3_REGION: {{ .Values.storage.s3.region | quote }} + S3_PREFIX: {{ .Values.storage.s3.prefix | quote }} + S3_FORCE_PATH_STYLE: {{ .Values.storage.s3.forcePathStyle | quote }} + {{- end }} + {{- if eq .Values.auth.mode "oidc" }} + OIDC_ISSUER_URL: {{ .Values.auth.oidc.issuerURL | quote }} + OIDC_EXTERNAL_ISSUER_URL: {{ .Values.auth.oidc.externalIssuerURL | quote }} + OIDC_CLIENT_ID: {{ .Values.auth.oidc.clientID | quote }} + OIDC_REDIRECT_URL: {{ .Values.auth.oidc.redirectURL | quote }} + OIDC_AUTO_ACTIVATE: {{ .Values.auth.oidc.autoActivate | quote }} + OIDC_DEFAULT_TEAM: {{ .Values.auth.oidc.defaultTeam | quote }} + OIDC_DEFAULT_ROLE: {{ .Values.auth.oidc.defaultRole | quote }} + OIDC_ROLES_CLAIM: {{ .Values.auth.oidc.rolesClaim | quote }} + OIDC_GROUPS_CLAIM: {{ .Values.auth.oidc.groupsClaim | quote }} + OIDC_ADMIN_ROLE: {{ .Values.auth.oidc.adminRole | quote }} + {{- end }} + {{- if eq .Values.auth.mode "mtls" }} + MTLS_HEADER_DN: {{ .Values.auth.mtls.headerDN | quote }} + MTLS_HEADER_VERIFY: {{ .Values.auth.mtls.headerVerify | quote }} + MTLS_HEADER_FINGERPRINT: {{ .Values.auth.mtls.headerFingerprint | quote }} + MTLS_AUTO_ACTIVATE: {{ .Values.auth.mtls.autoActivate | quote }} + MTLS_DEFAULT_TEAM: {{ .Values.auth.mtls.defaultTeam | quote }} + MTLS_DEFAULT_ROLE: {{ .Values.auth.mtls.defaultRole | quote }} + {{- end }} diff --git a/chart/templates/deployment-backend.yaml b/chart/templates/deployment-backend.yaml new file mode 100644 index 0000000..90a2168 --- /dev/null +++ b/chart/templates/deployment-backend.yaml @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-backend + labels: + {{- include "switchboard.backend.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.backend.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: backend + template: + metadata: + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: backend + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: backend + image: {{ include "switchboard.backend.image" . }} + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.backend.port }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ .Release.Name }}-config + - secretRef: + name: {{ include "switchboard.secretName" . }} + env: + - name: DATABASE_URL + value: {{ include "switchboard.databaseURL" . | quote }} + resources: + {{- toYaml .Values.backend.resources | nindent 12 }} + {{- if .Values.persistence.enabled }} + volumeMounts: + - name: data + mountPath: /data + {{- end }} + livenessProbe: + httpGet: + path: {{ .Values.basePath }}/api/v1/health + port: http + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: {{ .Values.basePath }}/api/v1/health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + {{- if .Values.persistence.enabled }} + volumes: + - name: data + persistentVolumeClaim: + claimName: {{ .Release.Name }}-data + {{- end }} diff --git a/chart/templates/deployment-frontend.yaml b/chart/templates/deployment-frontend.yaml new file mode 100644 index 0000000..9f0c90b --- /dev/null +++ b/chart/templates/deployment-frontend.yaml @@ -0,0 +1,50 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-frontend + labels: + {{- include "switchboard.frontend.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.frontend.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: frontend + template: + metadata: + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: frontend + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: frontend + image: {{ include "switchboard.frontend.image" . }} + imagePullPolicy: {{ .Values.frontend.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.frontend.port }} + protocol: TCP + env: + - name: BACKEND_URL + value: "http://{{ .Release.Name }}-backend:{{ .Values.backend.port }}" + - name: BASE_PATH + value: {{ .Values.basePath | quote }} + resources: + {{- toYaml .Values.frontend.resources | nindent 12 }} + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /health + port: http + periodSeconds: 10 diff --git a/chart/templates/ingress.yaml b/chart/templates/ingress.yaml new file mode 100644 index 0000000..11b5a8a --- /dev/null +++ b/chart/templates/ingress.yaml @@ -0,0 +1,51 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ .Release.Name }}-ingress + labels: + {{- include "switchboard.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls.enabled }} + tls: + - hosts: + - {{ .Values.ingress.host }} + {{- if .Values.ingress.tls.secretName }} + secretName: {{ .Values.ingress.tls.secretName }} + {{- end }} + {{- end }} + rules: + - host: {{ .Values.ingress.host }} + http: + paths: + # API + WebSocket → backend + - path: {{ .Values.basePath }}/api + pathType: Prefix + backend: + service: + name: {{ .Release.Name }}-backend + port: + number: {{ .Values.backend.port }} + - path: {{ .Values.basePath }}/ws + pathType: Prefix + backend: + service: + name: {{ .Release.Name }}-backend + port: + number: {{ .Values.backend.port }} + # Everything else → frontend (nginx serves static + proxies API) + - path: {{ .Values.basePath | default "/" }} + pathType: Prefix + backend: + service: + name: {{ .Release.Name }}-frontend + port: + number: {{ .Values.frontend.port }} +{{- end }} diff --git a/chart/templates/pvc.yaml b/chart/templates/pvc.yaml new file mode 100644 index 0000000..beeb9be --- /dev/null +++ b/chart/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if .Values.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ .Release.Name }}-data + labels: + {{- include "switchboard.labels" . | nindent 4 }} +spec: + accessModes: + - {{ .Values.persistence.accessMode }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +{{- end }} diff --git a/chart/templates/secret.yaml b/chart/templates/secret.yaml new file mode 100644 index 0000000..0f6b490 --- /dev/null +++ b/chart/templates/secret.yaml @@ -0,0 +1,31 @@ +{{- if not .Values.existingSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Release.Name }}-secrets + labels: + {{- include "switchboard.labels" . | nindent 4 }} +type: Opaque +stringData: + JWT_SECRET: {{ required "jwtSecret is required" .Values.jwtSecret | quote }} + {{- if .Values.encryptionKey }} + ENCRYPTION_KEY: {{ .Values.encryptionKey | quote }} + {{- end }} + {{- if .Values.database.postgres.password }} + POSTGRES_PASSWORD: {{ .Values.database.postgres.password | quote }} + {{- end }} + {{- if .Values.admin.password }} + SWITCHBOARD_ADMIN_USERNAME: {{ .Values.admin.username | quote }} + SWITCHBOARD_ADMIN_PASSWORD: {{ .Values.admin.password | quote }} + {{- end }} + {{- if .Values.admin.email }} + SWITCHBOARD_ADMIN_EMAIL: {{ .Values.admin.email | quote }} + {{- end }} + {{- if .Values.storage.s3.accessKey }} + S3_ACCESS_KEY: {{ .Values.storage.s3.accessKey | quote }} + S3_SECRET_KEY: {{ .Values.storage.s3.secretKey | quote }} + {{- end }} + {{- if and (eq .Values.auth.mode "oidc") .Values.auth.oidc.clientSecret }} + OIDC_CLIENT_SECRET: {{ .Values.auth.oidc.clientSecret | quote }} + {{- end }} +{{- end }} diff --git a/chart/templates/services.yaml b/chart/templates/services.yaml new file mode 100644 index 0000000..6000342 --- /dev/null +++ b/chart/templates/services.yaml @@ -0,0 +1,35 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-backend + labels: + {{- include "switchboard.backend.labels" . | nindent 4 }} +spec: + type: ClusterIP + ports: + - port: {{ .Values.backend.port }} + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: backend +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-frontend + labels: + {{- include "switchboard.frontend.labels" . | nindent 4 }} +spec: + type: ClusterIP + ports: + - port: {{ .Values.frontend.port }} + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: frontend diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 0000000..43146c3 --- /dev/null +++ b/chart/values.yaml @@ -0,0 +1,135 @@ +# Chat Switchboard — Helm values +# helm install switchboard ./chart + +# ── Images ───────────────────────────────── +backend: + image: + repository: git.gobha.me/xcaliber/chat-switchboard + tag: "" # defaults to Chart.appVersion + pullPolicy: IfNotPresent + replicaCount: 1 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi + port: 8080 + +frontend: + image: + repository: git.gobha.me/xcaliber/chat-switchboard-frontend + tag: "" # defaults to Chart.appVersion + pullPolicy: IfNotPresent + replicaCount: 1 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + port: 80 + +# ── Database ─────────────────────────────── +database: + # "postgres" or "sqlite" + driver: postgres + # For postgres: full DSN or assembled from postgres.* below + url: "" + postgres: + host: postgresql + port: 5432 + user: switchboard + password: "" # set via secret + database: switchboard + sslmode: disable + # For sqlite: path inside the container (requires persistence) + sqlitePath: /data/switchboard.db + +# ── Core ─────────────────────────────────── +basePath: "" # URL prefix, e.g. "/dev" +environment: production +jwtSecret: "" # REQUIRED — set via secret or --set +encryptionKey: "" # REQUIRED for BYOK — set via secret + +# ── Admin bootstrap ──────────────────────── +admin: + username: admin + password: "" # set via secret + email: "" + +# ── Storage ──────────────────────────────── +storage: + backend: pvc # "pvc" or "s3" + path: /data/storage + s3: + endpoint: "" + bucket: "" + region: us-east-1 + accessKey: "" + secretKey: "" + prefix: "" + forcePathStyle: true + +# ── Persistence (PVC) ───────────────────── +persistence: + enabled: true + storageClass: "" # use cluster default + size: 10Gi + accessMode: ReadWriteOnce + +# ── Ingress ──────────────────────────────── +ingress: + enabled: true + className: traefik + annotations: {} + host: switchboard.local + tls: + enabled: false + secretName: "" + +# ── Auth ─────────────────────────────────── +auth: + mode: builtin # builtin | mtls | oidc + oidc: + issuerURL: "" + externalIssuerURL: "" + clientID: "" + clientSecret: "" + redirectURL: "" + autoActivate: true + defaultTeam: "" + defaultRole: user + rolesClaim: "realm_access.roles" + groupsClaim: groups + adminRole: admin + mtls: + headerDN: X-SSL-Client-DN + headerVerify: X-SSL-Client-Verify + headerFingerprint: X-SSL-Client-Fingerprint + autoActivate: true + defaultTeam: "" + defaultRole: user + +# ── Tuning ───────────────────────────────── +sessionExpiryDays: 30 +workflowStaleHours: 72 +providerAutoDisableThreshold: 3 +extraction: + mode: inline + concurrency: 3 +workspace: + indexingEnabled: true + indexConcurrency: 2 + +# ── CORS ─────────────────────────────────── +corsAllowedOrigins: "*" + +# ── Existing secret reference ────────────── +# If set, skips creating a Secret and mounts this one instead. +existingSecret: "" + +# ── Image pull secrets ───────────────────── +imagePullSecrets: [] diff --git a/docs/ICD/admin.md b/docs/ICD/admin.md index c5e4aa5..fe3b243 100644 --- a/docs/ICD/admin.md +++ b/docs/ICD/admin.md @@ -234,3 +234,15 @@ POST /admin/tasks/:id/run → force run POST /admin/tasks/:id/kill → cancel active run DELETE /admin/tasks/:id → delete task ``` + +### Notifications Admin (v0.28.6) + +``` +POST /admin/notifications/broadcast ← { "title", "message", "level?" } + → { "message": "broadcast sent", "count": N } +``` + +Sends a `system.announcement` notification to all active users. +Also emits a `system.broadcast` WebSocket event for real-time delivery. +`level` is optional (`info` | `warning` | `critical`, default `info`). +See [notifications.md](notifications.md#admin-broadcast-v0286) for full details. diff --git a/docs/ICD/notifications.md b/docs/ICD/notifications.md index 727d3e5..4b96593 100644 --- a/docs/ICD/notifications.md +++ b/docs/ICD/notifications.md @@ -91,6 +91,42 @@ POST /admin/notifications/test-email → send test email to requesting ad Requires admin role. Loads SMTP config from platform settings, sends a test message to the admin's registered email address. +### Admin Broadcast (v0.28.6) + +``` +POST /admin/notifications/broadcast +``` + +**Auth:** Admin only. + +Sends a `system.announcement` notification to all active users via +`NotifyMany`. Also emits a `system.broadcast` WebSocket event for +real-time visibility. + +**Request body:** + +```json +{ + "title": "Scheduled Maintenance", + "message": "Servers will be down from 2-4 AM EST.", + "level": "warning" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `title` | string | yes | Short summary shown in bell dropdown | +| `message` | string | yes | Detail text | +| `level` | string | no | `info` (default), `warning`, or `critical` | + +**Response:** + +```json +{ "message": "broadcast sent", "count": 42 } +``` + +`count` is the number of active users notified. + ### WebSocket Events | Event | Payload | Description | @@ -98,6 +134,7 @@ sends a test message to the admin's registered email address. | `notification.new` | Full notification object | New notification created | | `notification.read` | `{"id": "uuid"}` | Single notification marked read | | `notification.read` | `{"action": "mark_all_read"}` | All notifications marked read | +| `system.broadcast` | `{"title", "message", "level"}` | Admin announcement (v0.28.6) | These are targeted via `Hub.SendToUser` — no room subscription required. Used for badge sync across tabs. @@ -106,7 +143,8 @@ required. Used for badge sync across tabs. See [enums.md](enums.md#notification-types) for the canonical list. Types are free-form strings (`domain.action` convention) — new types -don't require migration. +don't require migration. Core types include `system.announcement` +(admin broadcast, v0.28.6). ### Retention diff --git a/docs/ICD/tasks.md b/docs/ICD/tasks.md index c7d83a3..b0371f1 100644 --- a/docs/ICD/tasks.md +++ b/docs/ICD/tasks.md @@ -65,8 +65,17 @@ POST /tasks ``` `task_type`: `prompt` (direct LLM execution), `workflow` (deferred — -not yet implemented; creation is rejected at the API), or `action` -(no LLM — relay/webhook only; requires `task.action` permission). +not yet implemented; creation is rejected at the API), `action` +(no LLM — relay/webhook only; requires `task.action` permission), +or `system` (v0.28.6 — built-in Go function, admin-only). + +System tasks require `system_function` — a registered function name +from the platform's Go function registry. No LLM, no provider +resolution, no channel needed. The function receives the store set +and returns a result string. Designed for core platform ops +(`session_cleanup`, `staleness_check`, `retention_sweep`, `health_prune`) +that must not break from bad user code. Permanent track — not replaced +by Starlark in v0.29.0. `schedule`: cron expression, `once`, or `webhook`. Supported cron patterns: `@hourly`, `@daily`, `@weekly`, `*/N * * * *` (every N @@ -173,6 +182,26 @@ Returns `410 Gone` if the task is inactive. **Rate limiting:** Governed by global request rate limits. No per-task rate limiting in v0.28 — defer to a future version if needed. +### Task-to-Task Chaining (v0.28.6) + +Tasks can trigger other tasks by setting the `webhook_url` of Task A +to the trigger URL of Task B: + +1. Create **Task B** with `schedule: "webhook"`. Note the trigger URL + from the response (`/api/v1/hooks/t/{trigger_token}`). +2. Create **Task A** with `output_mode: "webhook"` and set `webhook_url` + to Task B's trigger URL. +3. When Task A completes, its output is POSTed to Task B's trigger URL. + Task B starts with Task A's output as its `trigger_payload`. + +This enables multi-step pipelines without external orchestration: +- Task A: "Summarize today's news" (prompt, cron: 6am) +- Task B: "Format and post to Slack" (action, webhook-triggered) + +The `webhook_secret` on Task A signs outbound payloads with HMAC-SHA256. +Task B's trigger endpoint does not currently verify signatures (the +trigger token itself is the auth mechanism). + ## Team Tasks Team-scoped tasks visible to all team members, manageable by team admins. @@ -240,6 +269,30 @@ DELETE /admin/tasks/:id **Auth:** Platform admin. +### System Functions (v0.28.6) + +``` +GET /admin/system-functions → { "data": [SystemFuncInfo, ...] } +``` + +Returns all registered built-in system function names and descriptions. +Used by the admin UI to populate the function dropdown when creating +system tasks. + +**SystemFuncInfo:** + +```json +{ + "name": "session_cleanup", + "description": "Remove expired anonymous visitor sessions" +} +``` + +Built-in functions (v0.28.6): `session_cleanup`, `staleness_check`, +`retention_sweep`, `health_prune`. + +**Auth:** Platform admin. + ## Task Object ```json @@ -250,7 +303,8 @@ DELETE /admin/tasks/:id "name": "Morning News Digest", "description": "...", "scope": "personal|team|global", - "task_type": "prompt|workflow|action", + "task_type": "prompt|workflow|action|system", + "system_function": "session_cleanup", "persona_id": "uuid|null", "model_id": "claude-sonnet-4-20250514", "system_prompt": "...", diff --git a/docs/ICD/workspaces.md b/docs/ICD/workspaces.md index 945d9d5..c414173 100644 --- a/docs/ICD/workspaces.md +++ b/docs/ICD/workspaces.md @@ -361,13 +361,15 @@ Independent of workspace scope — credentials are user-level. ``` POST /git-credentials ← { "name", "auth_type", ... } +POST /git-credentials/generate ← { "name", "persona_id?" } GET /git-credentials → { "data": [GitCredentialSummary] } +GET /git-credentials/:id/public-key → { "public_key", "fingerprint" } DELETE /git-credentials/:id ``` **Auth:** Authenticated user. Credentials are scoped to the requesting user. -**Create request:** +**Create request (manual):** `auth_type` determines which fields are required: @@ -379,6 +381,37 @@ DELETE /git-credentials/:id Returns `201` with the credential summary (never exposes encrypted data). +**Generate request (v0.28.6 — server-side SSH keygen):** + +```json +{ + "name": "GitHub - main", + "persona_id": "uuid" +} +``` + +Generates an ED25519 SSH keypair server-side. The private key is +vault-encrypted before storage and never leaves the server. Returns +the credential summary including the public key (authorized_keys +format) and SHA256 fingerprint. The user adds the public key to their +git host (GitHub, Gitea, etc.). + +Optional `persona_id` binds the key to a persona for commit +attribution — git operations using this credential will set the +commit author to the persona's name and handle. + +Returns `201`. + +**Get Public Key:** + +``` +GET /git-credentials/:id/public-key +``` + +Returns `{ "public_key": "ssh-ed25519 AAAA...", "fingerprint": "SHA256:..." }`. +Owner-only — returns `404` for other users or credentials without +a public key (e.g. PAT credentials). + **List response:** ```json @@ -388,12 +421,18 @@ Returns `201` with the credential summary (never exposes encrypted data). "id": "uuid", "name": "GitHub PAT", "auth_type": "https_pat", + "public_key": "", + "fingerprint": "", + "persona_id": null, "created_at": "2025-06-15T14:30:00Z" } ] } ``` +`public_key` and `fingerprint` are populated for `ssh_key` type +credentials created via `/generate`. Empty for PAT/basic auth. + **DELETE** returns `{"deleted": true}`. Returns `404` if credential not found or not owned by the requesting user. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e26ad1a..c0c9eb5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -30,9 +30,9 @@ v0.9.x–v0.27.5 Foundation → Extensions → Surfaces → Auth ✅ ├─ v0.28.5 Frontend SDK + Pipes ✅ │ (switchboard-sdk.js, pipe/filter │ pipeline, component mounting) - ├─ v0.28.6 Infrastructure - │ (virtual scroll, Helm, task webhook - │ UI, system tasks, model prefs) + ├─ v0.28.6 Infrastructure ✅ + │ (virtual scroll, Helm, system tasks, + │ broadcast, git keygen, model prefs) └─ v0.28.7 Unified Packaging + Task RBAC (.pkg archive, manifest.type, task permission gate pre-Starlark) @@ -295,27 +295,24 @@ Pipeline wires filters in priority order; any filter can halt the chain. - [x] Pipe filter tests: priority ordering, halt semantics, error isolation, scoped/unscoped execution -### v0.28.6 — Infrastructure -- [ ] Virtual scroll for long conversations (prerequisite for heavy task output channels) -- [ ] KB auto-injection: platform-registered pre-send pipe filter (`_kb-auto-inject`, - priority 5), top-K chunk prepend to `ctx.metadata.kb_context`, context budget aware, - per-channel toggle. First real validation of the v0.28.5 pipeline architecture. -- [ ] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`) -- [ ] Per-provider model preferences — finalize: make `provider_config_id` required on +### v0.28.6 — Infrastructure ✅ +- [x] Virtual scroll for long conversations (prerequisite for heavy task output channels) +- [x] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`) +- [x] Per-provider model preferences — finalize: make `provider_config_id` required on `PUT /models/preferences` (fixes NULL-in-UNIQUE dedup bug), migrate `/models/enabled` and `/models/preferences` to `{"data": [...]}` envelope, add integration test coverage -- [ ] Git credentials settings UI: vault-encrypted per-user credentials, SSH key - management, per-workspace remote config. Table exists (`git_credentials`), vault - pattern exists — needs settings section and CRUD handler. -- [ ] `system.announcement` notification type: admin broadcast endpoint +- [x] Git credentials settings UI: server-side ED25519 key generation (private key + vault-encrypted, public key exposed for git host), optional persona binding for + commit attribution, settings section with generate/list/copy/delete +- [x] `system.announcement` notification type: admin broadcast endpoint (`POST /admin/notifications/broadcast`), fan-out to all active users via `NotifyMany`, admin UI for composing announcements -- [ ] Task webhook trigger UI: schedule selector gains `webhook` option, +- [x] Task webhook trigger UI: schedule selector gains `webhook` option, trigger URL displayed + copy button, outbound `webhook_url` + `webhook_secret` fields in create/edit form, task-to-task chaining documentation in admin UI. Backend fully implemented — this is pure admin UI work. -- [ ] System task type: `task_type: "system"` — built-in Go function registry - (`retention_sweep`, `memory_compact`, `session_cleanup`, `staleness_check`). +- [x] System task type: `task_type: "system"` — built-in Go function registry + (`retention_sweep`, `health_prune`, `session_cleanup`, `staleness_check`). Admin creates task, picks function from dropdown, sets cron schedule. Executor calls registered Go function instead of LLM completion. Replaces the current goroutine-based background jobs with visible, configurable, auditable tasks. @@ -323,6 +320,9 @@ Pipeline wires filters in priority order; any filter can halt the chain. **Permanent track** — Go registry is not replaced by Starlark (v0.29.0). Core platform ops must not break from bad user code. Admin-only by design (no RBAC needed — hardcoded to admin role). +- KB auto-injection moved to v0.29.0 — built as the reference Go implementation + of the server-side filter model alongside Starlark. Ensures the filter + architecture is right and performant before user code runs in it. ### v0.28.7 — Unified Packaging + Task RBAC Single `.pkg` archive format for both surfaces and extensions. Manifest @@ -453,6 +453,12 @@ extension infrastructure (v0.11.0). data quality checks, integration sync, cleanup scripts. Editable, versionable, sandbox-isolated, permission-gated. +- [ ] KB auto-injection: server-side pre-completion filter (Go built-in, + not Starlark). Top-K chunk prepend from channel-bound KBs, context + budget aware, per-channel toggle. Reference implementation of the + server-side filter model that Starlark filters will mirror. Moved + from v0.28.6 to validate alongside the Starlark filter architecture. + --- ## v0.29.1 — API Extensions diff --git a/server/database/migrations/005_channels.sql b/server/database/migrations/005_channels.sql index 80c29a5..91f5eb3 100644 --- a/server/database/migrations/005_channels.sql +++ b/server/database/migrations/005_channels.sql @@ -217,7 +217,7 @@ CREATE TABLE IF NOT EXISTS user_model_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, model_id TEXT NOT NULL, - provider_config_id UUID REFERENCES provider_configs(id) ON DELETE CASCADE, + provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE, hidden BOOLEAN DEFAULT false, preferred_temperature FLOAT, preferred_max_tokens INT, diff --git a/server/database/migrations/009_workspaces.sql b/server/database/migrations/009_workspaces.sql index cac440f..df003f8 100644 --- a/server/database/migrations/009_workspaces.sql +++ b/server/database/migrations/009_workspaces.sql @@ -107,6 +107,9 @@ CREATE TABLE IF NOT EXISTS git_credentials ( auth_type TEXT NOT NULL DEFAULT 'https_pat', encrypted_data BYTEA NOT NULL DEFAULT '', nonce BYTEA NOT NULL DEFAULT '', + public_key TEXT NOT NULL DEFAULT '', + fingerprint TEXT NOT NULL DEFAULT '', + persona_id UUID REFERENCES personas(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); diff --git a/server/database/migrations/019_tasks.sql b/server/database/migrations/019_tasks.sql index 3cf44e0..409e852 100644 --- a/server/database/migrations/019_tasks.sql +++ b/server/database/migrations/019_tasks.sql @@ -21,7 +21,8 @@ CREATE TABLE IF NOT EXISTS tasks ( -- What to run task_type TEXT NOT NULL DEFAULT 'prompt' - CHECK (task_type IN ('prompt', 'workflow', 'action')), + CHECK (task_type IN ('prompt', 'workflow', 'action', 'system')), + system_function TEXT DEFAULT '', persona_id UUID REFERENCES personas(id) ON DELETE SET NULL, model_id TEXT, system_prompt TEXT DEFAULT '', diff --git a/server/database/migrations/sqlite/005_channels.sql b/server/database/migrations/sqlite/005_channels.sql index 6b55cce..051e2a5 100644 --- a/server/database/migrations/sqlite/005_channels.sql +++ b/server/database/migrations/sqlite/005_channels.sql @@ -133,7 +133,7 @@ CREATE TABLE IF NOT EXISTS user_model_settings ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, model_id TEXT NOT NULL, - provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE CASCADE, + provider_config_id TEXT NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE, hidden INTEGER DEFAULT 0, preferred_temperature REAL, preferred_max_tokens INTEGER, diff --git a/server/database/migrations/sqlite/009_workspaces.sql b/server/database/migrations/sqlite/009_workspaces.sql index ddabf6b..2c1c8d1 100644 --- a/server/database/migrations/sqlite/009_workspaces.sql +++ b/server/database/migrations/sqlite/009_workspaces.sql @@ -60,6 +60,9 @@ CREATE TABLE IF NOT EXISTS git_credentials ( auth_type TEXT NOT NULL DEFAULT 'https_pat', encrypted_data BLOB NOT NULL DEFAULT '', nonce BLOB NOT NULL DEFAULT '', + public_key TEXT NOT NULL DEFAULT '', + fingerprint TEXT NOT NULL DEFAULT '', + persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); diff --git a/server/database/migrations/sqlite/019_tasks.sql b/server/database/migrations/sqlite/019_tasks.sql index de405ea..eda98e7 100644 --- a/server/database/migrations/sqlite/019_tasks.sql +++ b/server/database/migrations/sqlite/019_tasks.sql @@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS tasks ( description TEXT DEFAULT '', scope TEXT NOT NULL DEFAULT 'personal', task_type TEXT NOT NULL DEFAULT 'prompt', + system_function TEXT DEFAULT '', persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL, model_id TEXT, system_prompt TEXT DEFAULT '', diff --git a/server/handlers/git.go b/server/handlers/git.go index 413162d..6b91fc1 100644 --- a/server/handlers/git.go +++ b/server/handlers/git.go @@ -1,11 +1,16 @@ package handlers import ( + "crypto/ed25519" + "crypto/rand" "encoding/json" + "encoding/pem" "fmt" "net/http" "strconv" + "golang.org/x/crypto/ssh" + "git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" @@ -334,3 +339,98 @@ func (h *GitCredentialHandler) Delete(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"deleted": true}) } + +// Generate creates a new ED25519 SSH keypair server-side. +// The private key is vault-encrypted before storage. Only the public key +// and fingerprint are returned — the private key never leaves the server. +// +// POST /git-credentials/generate +func (h *GitCredentialHandler) Generate(c *gin.Context) { + userID := getUserID(c) + var req struct { + Name string `json:"name" binding:"required"` + PersonaID *string `json:"persona_id,omitempty"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Generate ED25519 keypair + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "key generation failed"}) + return + } + + // Marshal public key to authorized_keys format + sshPub, err := ssh.NewPublicKey(pubKey) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "public key encoding failed"}) + return + } + authorizedKey := string(ssh.MarshalAuthorizedKey(sshPub)) + + // Fingerprint (SHA256) + fingerprint := ssh.FingerprintSHA256(sshPub) + + // Marshal private key to OpenSSH PEM format + privPEM, err := ssh.MarshalPrivateKey(privKey, "") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "private key encoding failed"}) + return + } + privPEMBytes := pem.EncodeToMemory(privPEM) + + // Encrypt private key via vault + credData := map[string]string{"private_key": string(privPEMBytes)} + plaintext, _ := json.Marshal(credData) + + ciphertext, nonce, err := h.vault.EncryptForScope(string(plaintext), "global", userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "encryption failed"}) + return + } + + cred := &models.GitCredential{ + UserID: userID, + Name: req.Name, + AuthType: "ssh_key", + EncryptedData: ciphertext, + Nonce: nonce, + PublicKey: authorizedKey, + Fingerprint: fingerprint, + PersonaID: req.PersonaID, + } + + if err := h.stores.GitCredentials.Create(c.Request.Context(), cred); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, cred.Summary()) +} + +// GetPublicKey returns the public key for a credential (for copy-to-clipboard). +// +// GET /git-credentials/:id/public-key +func (h *GitCredentialHandler) GetPublicKey(c *gin.Context) { + userID := getUserID(c) + credID := c.Param("id") + + cred, err := h.stores.GitCredentials.GetByID(c.Request.Context(), credID) + if err != nil || cred.UserID != userID { + c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"}) + return + } + + if cred.PublicKey == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "no public key for this credential type"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "public_key": cred.PublicKey, + "fingerprint": cred.Fingerprint, + }) +} diff --git a/server/handlers/git_credentials_test.go b/server/handlers/git_credentials_test.go new file mode 100644 index 0000000..33ef820 --- /dev/null +++ b/server/handlers/git_credentials_test.go @@ -0,0 +1,281 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/config" + "git.gobha.me/xcaliber/chat-switchboard/crypto" + "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/middleware" + "git.gobha.me/xcaliber/chat-switchboard/store" + postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" + sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" +) + +// ── Git Credentials Test Harness ────────── + +type gitCredHarness struct { + *testHarness + userToken string + userID string + user2Token string + user2ID string +} + +func setupGitCredHarness(t *testing.T) *gitCredHarness { + t.Helper() + database.RequireTestDB(t) + database.TruncateAll(t) + + cfg := &config.Config{ + JWTSecret: testJWTSecret, + BasePath: "", + } + + var stores store.Stores + if database.IsSQLite() { + stores = sqlite.NewStores(database.TestDB) + } else { + stores = postgres.NewStores(database.TestDB) + } + userCache := middleware.NewUserStatusCache() + + // Create a test vault with a static env key (32 bytes for AES-256) + envKey := []byte("test-encryption-key-32-bytes!!") + for len(envKey) < 32 { + envKey = append(envKey, '0') + } + envKey = envKey[:32] + vault := crypto.NewKeyResolver(envKey, nil) + + r := gin.New() + api := r.Group("/api/v1") + protected := api.Group("") + protected.Use(middleware.Auth(cfg, stores.Users, userCache)) + + gitCredH := NewGitCredentialHandler(stores, vault) + protected.POST("/git-credentials", gitCredH.Create) + protected.POST("/git-credentials/generate", gitCredH.Generate) + protected.GET("/git-credentials", gitCredH.List) + protected.GET("/git-credentials/:id/public-key", gitCredH.GetPublicKey) + protected.DELETE("/git-credentials/:id", gitCredH.Delete) + + // Seed users + userID := database.SeedTestUser(t, "gituser", "gituser@test.com") + database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID) + userToken := makeToken(userID, "gituser@test.com", "user") + + user2ID := database.SeedTestUser(t, "gituser2", "gituser2@test.com") + database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), user2ID) + user2Token := makeToken(user2ID, "gituser2@test.com", "user") + + return &gitCredHarness{ + testHarness: &testHarness{router: r, t: t}, + userToken: userToken, + userID: userID, + user2Token: user2Token, + user2ID: user2ID, + } +} + +// ── GET /git-credentials — empty state ─── + +func TestGitCreds_List_Empty(t *testing.T) { + h := setupGitCredHarness(t) + + resp := h.request("GET", "/api/v1/git-credentials", h.userToken, nil) + if resp.Code != http.StatusOK { + t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) + } + + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + data, ok := body["data"] + if !ok { + t.Fatal("response must have 'data' key") + } + arr := data.([]interface{}) + if len(arr) != 0 { + t.Fatalf("expected empty array, got %d items", len(arr)) + } +} + +// ── POST /git-credentials/generate ─────── + +func TestGitCreds_Generate(t *testing.T) { + h := setupGitCredHarness(t) + + resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{ + "name": "Test Key", + }) + if resp.Code != http.StatusCreated { + t.Fatalf("generate: got %d, body: %s", resp.Code, resp.Body.String()) + } + + var cred map[string]interface{} + json.NewDecoder(resp.Body).Decode(&cred) + + // Must have public_key and fingerprint + pubKey, _ := cred["public_key"].(string) + fp, _ := cred["fingerprint"].(string) + if pubKey == "" { + t.Error("public_key should be non-empty") + } + if fp == "" { + t.Error("fingerprint should be non-empty") + } + if cred["auth_type"] != "ssh_key" { + t.Errorf("auth_type: got %v, want ssh_key", cred["auth_type"]) + } + if cred["name"] != "Test Key" { + t.Errorf("name: got %v", cred["name"]) + } + + // Must NOT have encrypted data + if _, has := cred["encrypted_data"]; has { + t.Error("encrypted_data must not appear in response") + } + if _, has := cred["nonce"]; has { + t.Error("nonce must not appear in response") + } +} + +// ── GET /git-credentials/:id/public-key ── + +func TestGitCreds_GetPublicKey(t *testing.T) { + h := setupGitCredHarness(t) + + // Generate a key first + resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{ + "name": "PK Test", + }) + var cred map[string]interface{} + json.NewDecoder(resp.Body).Decode(&cred) + id := cred["id"].(string) + origPK := cred["public_key"].(string) + + // Retrieve public key + resp = h.request("GET", "/api/v1/git-credentials/"+id+"/public-key", h.userToken, nil) + if resp.Code != http.StatusOK { + t.Fatalf("get public-key: got %d, body: %s", resp.Code, resp.Body.String()) + } + + var pkBody map[string]interface{} + json.NewDecoder(resp.Body).Decode(&pkBody) + if pkBody["public_key"] != origPK { + t.Errorf("public key mismatch") + } + if pkBody["fingerprint"] == nil || pkBody["fingerprint"] == "" { + t.Error("fingerprint should be present") + } +} + +// ── List after generate ────────────────── + +func TestGitCreds_ListAfterGenerate(t *testing.T) { + h := setupGitCredHarness(t) + + h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{ + "name": "Key A", + }) + h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{ + "name": "Key B", + }) + + resp := h.request("GET", "/api/v1/git-credentials", h.userToken, nil) + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + arr := body["data"].([]interface{}) + if len(arr) != 2 { + t.Fatalf("expected 2 keys, got %d", len(arr)) + } + + // Verify summaries have public_key + fingerprint + for _, raw := range arr { + item := raw.(map[string]interface{}) + if item["public_key"] == nil || item["public_key"] == "" { + t.Error("list item should have public_key") + } + if item["fingerprint"] == nil || item["fingerprint"] == "" { + t.Error("list item should have fingerprint") + } + } +} + +// ── Delete ─────────────────────────────── + +func TestGitCreds_Delete(t *testing.T) { + h := setupGitCredHarness(t) + + resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{ + "name": "Delete Me", + }) + var cred map[string]interface{} + json.NewDecoder(resp.Body).Decode(&cred) + id := cred["id"].(string) + + // Delete + resp = h.request("DELETE", "/api/v1/git-credentials/"+id, h.userToken, nil) + if resp.Code != http.StatusOK { + t.Fatalf("delete: got %d", resp.Code) + } + + // Verify gone + resp = h.request("GET", "/api/v1/git-credentials", h.userToken, nil) + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + arr := body["data"].([]interface{}) + if len(arr) != 0 { + t.Fatalf("expected 0 keys after delete, got %d", len(arr)) + } +} + +// ── User isolation ─────────────────────── + +func TestGitCreds_UserIsolation(t *testing.T) { + h := setupGitCredHarness(t) + + // User 1 generates a key + resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{ + "name": "User1 Key", + }) + var cred map[string]interface{} + json.NewDecoder(resp.Body).Decode(&cred) + id := cred["id"].(string) + + // User 2 should see empty list + resp = h.request("GET", "/api/v1/git-credentials", h.user2Token, nil) + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + arr := body["data"].([]interface{}) + if len(arr) != 0 { + t.Fatalf("user2 should see 0 keys, got %d", len(arr)) + } + + // User 2 should not be able to get user1's public key + resp = h.request("GET", "/api/v1/git-credentials/"+id+"/public-key", h.user2Token, nil) + if resp.Code != http.StatusNotFound { + t.Fatalf("cross-user public-key: want 404, got %d", resp.Code) + } + + // User 2 should not be able to delete user1's key + resp = h.request("DELETE", "/api/v1/git-credentials/"+id, h.user2Token, nil) + if resp.Code != http.StatusNotFound { + t.Fatalf("cross-user delete: want 404, got %d", resp.Code) + } +} + +// ── Validation ─────────────────────────── + +func TestGitCreds_Generate_MissingName(t *testing.T) { + h := setupGitCredHarness(t) + + resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{}) + if resp.Code != http.StatusBadRequest { + t.Fatalf("missing name: want 400, got %d", resp.Code) + } +} diff --git a/server/handlers/model_prefs_test.go b/server/handlers/model_prefs_test.go new file mode 100644 index 0000000..db42251 --- /dev/null +++ b/server/handlers/model_prefs_test.go @@ -0,0 +1,363 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "git.gobha.me/xcaliber/chat-switchboard/config" + "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/middleware" + "git.gobha.me/xcaliber/chat-switchboard/store" + postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" + sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" +) + +// ── Model Prefs Test Harness ────────────── + +type modelPrefsHarness struct { + *testHarness + userToken string + userID string + user2Token string + user2ID string + configID string // seeded provider_config for preference tests +} + +func setupModelPrefsHarness(t *testing.T) *modelPrefsHarness { + t.Helper() + database.RequireTestDB(t) + database.TruncateAll(t) + + cfg := &config.Config{ + JWTSecret: testJWTSecret, + BasePath: "", + } + + var stores store.Stores + if database.IsSQLite() { + stores = sqlite.NewStores(database.TestDB) + } else { + stores = postgres.NewStores(database.TestDB) + } + userCache := middleware.NewUserStatusCache() + + r := gin.New() + api := r.Group("/api/v1") + protected := api.Group("") + protected.Use(middleware.Auth(cfg, stores.Users, userCache)) + + modelPrefs := NewModelPrefsHandler(stores) + protected.GET("/models/preferences", modelPrefs.GetPreferences) + protected.PUT("/models/preferences", modelPrefs.SetPreference) + protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences) + + // Seed two users + userID := database.SeedTestUser(t, "prefuser", "prefuser@test.com") + database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID) + userToken := makeToken(userID, "prefuser@test.com", "user") + + user2ID := database.SeedTestUser(t, "prefuser2", "prefuser2@test.com") + database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), user2ID) + user2Token := makeToken(user2ID, "prefuser2@test.com", "user") + + // Seed a provider config for preference targets + configID := uuid.New().String() + if database.IsSQLite() { + database.TestDB.Exec(` + INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope) + VALUES (?, 'global', 'Test Provider', 'openai', 'https://api.openai.com/v1', 'global')`, + configID) + } else { + database.TestDB.Exec(` + INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope) + VALUES ($1, 'global', 'Test Provider', 'openai', 'https://api.openai.com/v1', 'global')`, + configID) + } + + return &modelPrefsHarness{ + testHarness: &testHarness{router: r, t: t}, + userToken: userToken, + userID: userID, + user2Token: user2Token, + user2ID: user2ID, + configID: configID, + } +} + +// ── GET /models/preferences — empty state ─ + +func TestModelPrefs_Get_Empty(t *testing.T) { + h := setupModelPrefsHarness(t) + + resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil) + if resp.Code != http.StatusOK { + t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) + } + + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + + data, ok := body["data"] + if !ok { + t.Fatal("response must have 'data' key") + } + arr, ok := data.([]interface{}) + if !ok { + t.Fatal("data must be an array") + } + if len(arr) != 0 { + t.Fatalf("expected empty array, got %d items", len(arr)) + } +} + +// ── PUT + GET round-trip ────────────────── + +func TestModelPrefs_Set_RoundTrip(t *testing.T) { + h := setupModelPrefsHarness(t) + + // Set preference + resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ + "model_id": "test-model-1", + "provider_config_id": h.configID, + "hidden": true, + "sort_order": 5, + }) + if resp.Code != http.StatusOK { + t.Fatalf("PUT: got %d, body: %s", resp.Code, resp.Body.String()) + } + + // Read back + resp = h.request("GET", "/api/v1/models/preferences", h.userToken, nil) + if resp.Code != http.StatusOK { + t.Fatalf("GET: got %d", resp.Code) + } + + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + arr := body["data"].([]interface{}) + if len(arr) != 1 { + t.Fatalf("expected 1 preference, got %d", len(arr)) + } + + pref := arr[0].(map[string]interface{}) + if pref["model_id"] != "test-model-1" { + t.Errorf("model_id: got %v", pref["model_id"]) + } + if pref["provider_config_id"] != h.configID { + t.Errorf("provider_config_id: got %v, want %s", pref["provider_config_id"], h.configID) + } + if pref["hidden"] != true { + t.Errorf("hidden: got %v, want true", pref["hidden"]) + } + if pref["sort_order"] != float64(5) { + t.Errorf("sort_order: got %v, want 5", pref["sort_order"]) + } +} + +// ── Upsert — no duplicate rows ──────────── + +func TestModelPrefs_Upsert_NoDuplicate(t *testing.T) { + h := setupModelPrefsHarness(t) + + for _, hidden := range []bool{true, false, true} { + resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ + "model_id": "test-model-1", + "provider_config_id": h.configID, + "hidden": hidden, + }) + if resp.Code != http.StatusOK { + t.Fatalf("PUT hidden=%v: got %d", hidden, resp.Code) + } + } + + resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil) + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + arr := body["data"].([]interface{}) + if len(arr) != 1 { + t.Fatalf("upsert produced %d rows, want 1", len(arr)) + } + + pref := arr[0].(map[string]interface{}) + if pref["hidden"] != true { + t.Errorf("final hidden state should be true, got %v", pref["hidden"]) + } +} + +// ── Validation: missing fields ──────────── + +func TestModelPrefs_Set_MissingModelID(t *testing.T) { + h := setupModelPrefsHarness(t) + + resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ + "provider_config_id": h.configID, + "hidden": true, + }) + if resp.Code != http.StatusBadRequest { + t.Fatalf("missing model_id: want 400, got %d", resp.Code) + } +} + +func TestModelPrefs_Set_MissingProviderConfigID(t *testing.T) { + h := setupModelPrefsHarness(t) + + resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ + "model_id": "test-model-1", + "hidden": true, + }) + if resp.Code != http.StatusBadRequest { + t.Fatalf("missing provider_config_id: want 400, got %d", resp.Code) + } +} + +// ── Bulk set hidden ─────────────────────── + +func TestModelPrefs_BulkSetHidden(t *testing.T) { + h := setupModelPrefsHarness(t) + + resp := h.request("POST", "/api/v1/models/preferences/bulk", h.userToken, map[string]interface{}{ + "entries": []map[string]string{ + {"model_id": "bulk-model-a", "provider_config_id": h.configID}, + {"model_id": "bulk-model-b", "provider_config_id": h.configID}, + }, + "hidden": true, + }) + if resp.Code != http.StatusOK { + t.Fatalf("bulk: got %d, body: %s", resp.Code, resp.Body.String()) + } + + var bulkResp map[string]interface{} + json.NewDecoder(resp.Body).Decode(&bulkResp) + if bulkResp["count"] != float64(2) { + t.Errorf("bulk count: got %v, want 2", bulkResp["count"]) + } + + // Verify both are hidden + resp = h.request("GET", "/api/v1/models/preferences", h.userToken, nil) + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + arr := body["data"].([]interface{}) + + hiddenCount := 0 + for _, raw := range arr { + p := raw.(map[string]interface{}) + if p["hidden"] == true { + hiddenCount++ + } + } + if hiddenCount < 2 { + t.Errorf("expected at least 2 hidden prefs, got %d", hiddenCount) + } +} + +// ── User isolation ──────────────────────── + +func TestModelPrefs_UserIsolation(t *testing.T) { + h := setupModelPrefsHarness(t) + + // User 1 sets a preference + resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ + "model_id": "isolated-model", + "provider_config_id": h.configID, + "hidden": true, + }) + if resp.Code != http.StatusOK { + t.Fatalf("user1 PUT: got %d", resp.Code) + } + + // User 2 should see empty preferences + resp = h.request("GET", "/api/v1/models/preferences", h.user2Token, nil) + if resp.Code != http.StatusOK { + t.Fatalf("user2 GET: got %d", resp.Code) + } + + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + arr := body["data"].([]interface{}) + if len(arr) != 0 { + t.Fatalf("user2 should have 0 prefs, got %d", len(arr)) + } +} + +// ── Shape validation ────────────────────── + +func TestModelPrefs_ResponseShape(t *testing.T) { + h := setupModelPrefsHarness(t) + + // Create a preference so we have something to inspect + h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ + "model_id": "shape-model", + "provider_config_id": h.configID, + "hidden": false, + "sort_order": 0, + }) + + resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil) + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + arr := body["data"].([]interface{}) + if len(arr) == 0 { + t.Fatal("expected at least 1 preference for shape check") + } + + pref := arr[0].(map[string]interface{}) + requiredFields := []string{"id", "user_id", "model_id", "provider_config_id", "hidden", "sort_order", "created_at", "updated_at"} + for _, f := range requiredFields { + if _, ok := pref[f]; !ok { + t.Errorf("missing required field %q in preference response", f) + } + } + + // id, user_id, model_id, provider_config_id should be non-empty strings + for _, f := range []string{"id", "user_id", "model_id", "provider_config_id"} { + v, _ := pref[f].(string) + if v == "" { + t.Errorf("field %q should be a non-empty string, got %v", f, pref[f]) + } + } +} + +// ── Different provider_config_id = different entry ── + +func TestModelPrefs_SameModel_DifferentProvider(t *testing.T) { + h := setupModelPrefsHarness(t) + + // Seed a second provider config + config2ID := uuid.New().String() + database.TestDB.Exec(dialectSQL( + "INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope) VALUES ($1, 'global', 'Test Provider 2', 'anthropic', 'https://api.anthropic.com/v1', 'global')"), + config2ID) + + // Set preference for same model under two providers + h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ + "model_id": "dual-provider-model", + "provider_config_id": h.configID, + "hidden": true, + }) + h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ + "model_id": "dual-provider-model", + "provider_config_id": config2ID, + "hidden": false, + }) + + // Should have two distinct entries + resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil) + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + arr := body["data"].([]interface{}) + + matches := 0 + for _, raw := range arr { + p := raw.(map[string]interface{}) + if p["model_id"] == "dual-provider-model" { + matches++ + } + } + if matches != 2 { + t.Fatalf("same model, different providers: expected 2 entries, got %d", matches) + } +} diff --git a/server/handlers/notification_test.go b/server/handlers/notification_test.go index 026fd19..515c8e9 100644 --- a/server/handlers/notification_test.go +++ b/server/handlers/notification_test.go @@ -12,6 +12,7 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/middleware" "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/notifications" "git.gobha.me/xcaliber/chat-switchboard/store" postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" @@ -61,6 +62,16 @@ func setupNotifHarness(t *testing.T) *notifHarness { protected.PUT("/notifications/preferences/:type", notifH.SetPreference) protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference) + // Admin broadcast route (v0.28.6) + admin := api.Group("/admin") + admin.Use(middleware.Auth(cfg, stores.Users, userCache)) + admin.Use(middleware.RequireAdmin()) + admin.POST("/notifications/broadcast", notifH.Broadcast) + + // Set up notification service singleton for Broadcast handler + notifSvc := notifications.NewService(stores.Notifications, nil) + notifications.SetDefault(notifSvc) + // Seed admin adminID := database.SeedTestUser(t, "notif-admin", "notif-admin@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), adminID) @@ -548,6 +559,110 @@ func TestNotifications_Unauthenticated(t *testing.T) { } } +// ── Admin Broadcast (v0.28.6) ──────────── + +func TestBroadcast_Success(t *testing.T) { + h := setupNotifHarness(t) + + resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{ + "title": "Maintenance Tonight", + "message": "Servers will be down from 2-4 AM.", + "level": "warning", + }) + if resp.Code != http.StatusOK { + t.Fatalf("broadcast: got %d, body: %s", resp.Code, resp.Body.String()) + } + + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + if body["message"] != "broadcast sent" { + t.Errorf("expected 'broadcast sent', got %v", body["message"]) + } + count := body["count"].(float64) + if count < 2 { + t.Errorf("expected at least 2 recipients (admin+user), got %v", count) + } + + // Verify notification created for regular user + resp = h.request("GET", "/api/v1/notifications", h.userToken, nil) + if resp.Code != http.StatusOK { + t.Fatalf("list notifications: got %d", resp.Code) + } + var listBody map[string]interface{} + json.NewDecoder(resp.Body).Decode(&listBody) + items := listBody["data"].([]interface{}) + if len(items) == 0 { + t.Fatal("user should have received the broadcast notification") + } + n := items[0].(map[string]interface{}) + if n["type"] != "system.announcement" { + t.Errorf("notification type: got %v, want system.announcement", n["type"]) + } + if n["title"] != "Maintenance Tonight" { + t.Errorf("notification title: got %v", n["title"]) + } +} + +func TestBroadcast_NonAdmin403(t *testing.T) { + h := setupNotifHarness(t) + + resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.userToken, map[string]interface{}{ + "title": "Unauthorized", + "message": "Should fail.", + }) + if resp.Code != http.StatusForbidden { + t.Fatalf("non-admin broadcast: want 403, got %d", resp.Code) + } +} + +func TestBroadcast_MissingTitle(t *testing.T) { + h := setupNotifHarness(t) + + resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{ + "message": "No title.", + }) + if resp.Code != http.StatusBadRequest { + t.Fatalf("missing title: want 400, got %d", resp.Code) + } +} + +func TestBroadcast_MissingMessage(t *testing.T) { + h := setupNotifHarness(t) + + resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{ + "title": "No message.", + }) + if resp.Code != http.StatusBadRequest { + t.Fatalf("missing message: want 400, got %d", resp.Code) + } +} + +func TestBroadcast_InvalidLevel(t *testing.T) { + h := setupNotifHarness(t) + + resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{ + "title": "Bad level", + "message": "Test", + "level": "extreme", + }) + if resp.Code != http.StatusBadRequest { + t.Fatalf("invalid level: want 400, got %d", resp.Code) + } +} + +func TestBroadcast_DefaultLevel(t *testing.T) { + h := setupNotifHarness(t) + + // Omit level — should default to info and succeed + resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{ + "title": "Info broadcast", + "message": "Default level test.", + }) + if resp.Code != http.StatusOK { + t.Fatalf("default level: want 200, got %d, body: %s", resp.Code, resp.Body.String()) + } +} + // ── helpers ──────────────────────────────── func keys(m map[string]json.RawMessage) []string { diff --git a/server/handlers/notifications.go b/server/handlers/notifications.go index 9d6d77a..de4dd8a 100644 --- a/server/handlers/notifications.go +++ b/server/handlers/notifications.go @@ -3,6 +3,7 @@ package handlers import ( "database/sql" "encoding/json" + "log" "net/http" "strconv" "time" @@ -11,6 +12,7 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/events" "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/notifications" "git.gobha.me/xcaliber/chat-switchboard/store" ) @@ -279,3 +281,74 @@ func (h *NotificationHandler) DeletePreference(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) } + +// ── Admin Broadcast (v0.28.6) ─────────────── + +// POST /api/v1/admin/notifications/broadcast +// Admin-only: sends a system.announcement notification to all active users. +func (h *NotificationHandler) Broadcast(c *gin.Context) { + var req struct { + Title string `json:"title" binding:"required"` + Message string `json:"message" binding:"required"` + Level string `json:"level"` // info | warning | critical (default: info) + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + level := req.Level + if level == "" { + level = "info" + } + if level != "info" && level != "warning" && level != "critical" { + c.JSON(http.StatusBadRequest, gin.H{"error": "level must be info, warning, or critical"}) + return + } + + // Get all active user IDs + userIDs, err := h.stores.Users.ListActiveUserIDs(c.Request.Context()) + if err != nil { + log.Printf("[broadcast] failed to list active users: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"}) + return + } + + if len(userIDs) == 0 { + c.JSON(http.StatusOK, gin.H{"message": "no active users", "count": 0}) + return + } + + // Fan out via notification service + svc := notifications.Default() + if svc == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "notification service not available"}) + return + } + + template := models.Notification{ + Type: models.NotifTypeAnnouncement, + Title: req.Title, + Body: req.Message, + } + svc.NotifyMany(c.Request.Context(), userIDs, template) + + // Also emit a real-time WS broadcast for immediate visibility + if h.hub != nil { + payload, _ := json.Marshal(map[string]string{ + "title": req.Title, + "message": req.Message, + "level": level, + }) + for _, uid := range userIDs { + h.hub.SendToUser(uid, events.Event{ + Label: "system.broadcast", + Payload: payload, + Ts: time.Now().UnixMilli(), + }) + } + } + + log.Printf("[broadcast] admin sent announcement to %d users: %s", len(userIDs), req.Title) + c.JSON(http.StatusOK, gin.H{"message": "broadcast sent", "count": len(userIDs)}) +} diff --git a/server/handlers/tasks.go b/server/handlers/tasks.go index 0c9b393..ef5cc01 100644 --- a/server/handlers/tasks.go +++ b/server/handlers/tasks.go @@ -141,6 +141,22 @@ func (h *TaskHandler) Create(c *gin.Context) { return } + // v0.28.6: System tasks — admin-only, requires valid system_function + if t.TaskType == "system" { + if c.GetString("role") != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "system tasks are admin-only"}) + return + } + if t.SystemFunction == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "system_function is required for system tasks"}) + return + } + if err := taskutil.ValidateSystemFunc(t.SystemFunction); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } + // Validate required fields if t.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) @@ -540,3 +556,9 @@ func (h *TriggerHandler) Handle(c *gin.Context) { "task_id": task.ID, }) } + +// ListSystemFunctions returns the available system function names and descriptions. +// GET /admin/system-functions +func (h *TaskHandler) ListSystemFunctions(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"data": taskutil.ListSystemFuncs()}) +} diff --git a/server/main.go b/server/main.go index 8a49f27..3fae41f 100644 --- a/server/main.go +++ b/server/main.go @@ -408,6 +408,9 @@ func main() { // v0.27.2: Task scheduler with executor — needs hub + notification service if stores.Tasks != nil { + // v0.28.6: Register built-in system task functions + scheduler.RegisterBuiltins() + exec := scheduler.NewExecutor(stores, keyResolver, hub, healthAccum) taskSched := scheduler.New(stores, exec) go taskSched.Run() @@ -813,7 +816,9 @@ func main() { // Git credentials (v0.21.4) — user-scoped, independent of workspace gitCredH := handlers.NewGitCredentialHandler(stores, keyResolver) protected.POST("/git-credentials", gitCredH.Create) + protected.POST("/git-credentials/generate", gitCredH.Generate) protected.GET("/git-credentials", gitCredH.List) + protected.GET("/git-credentials/:id/public-key", gitCredH.GetPublicKey) protected.DELETE("/git-credentials/:id", gitCredH.Delete) // Files (upload/download) @@ -1017,6 +1022,10 @@ func main() { admin.PUT("/teams/:id/members/:memberId", teamAdm.UpdateMember) admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember) + // Admin broadcast (v0.28.6) + adminNotifH := handlers.NewNotificationHandler(stores, hub) + admin.POST("/notifications/broadcast", adminNotifH.Broadcast) + // Audit log admin.GET("/audit", adm.ListAuditLog) admin.GET("/audit/actions", adm.ListAuditActions) @@ -1134,6 +1143,7 @@ func main() { admin.POST("/tasks/:id/run", taskAdm.RunNow) admin.POST("/tasks/:id/kill", taskAdm.KillRun) admin.DELETE("/tasks/:id", taskAdm.Delete) + admin.GET("/system-functions", taskAdm.ListSystemFunctions) } } diff --git a/server/models/models_git.go b/server/models/models_git.go index efaabe4..de897b9 100644 --- a/server/models/models_git.go +++ b/server/models/models_git.go @@ -13,24 +13,33 @@ type GitCredential struct { AuthType string `json:"auth_type" db:"auth_type"` // https_pat, https_basic, ssh_key EncryptedData []byte `json:"-" db:"encrypted_data"` // never expose Nonce []byte `json:"-" db:"nonce"` // never expose + PublicKey string `json:"public_key,omitempty" db:"public_key"` + Fingerprint string `json:"fingerprint,omitempty" db:"fingerprint"` + PersonaID *string `json:"persona_id,omitempty" db:"persona_id"` CreatedAt time.Time `json:"created_at" db:"created_at"` } // GitCredentialSummary is the safe-to-expose version (no encrypted data). type GitCredentialSummary struct { - ID string `json:"id"` - Name string `json:"name"` - AuthType string `json:"auth_type"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + Name string `json:"name"` + AuthType string `json:"auth_type"` + PublicKey string `json:"public_key,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + PersonaID *string `json:"persona_id,omitempty"` + CreatedAt time.Time `json:"created_at"` } // Summary returns a safe version without encrypted fields. func (c *GitCredential) Summary() GitCredentialSummary { return GitCredentialSummary{ - ID: c.ID, - Name: c.Name, - AuthType: c.AuthType, - CreatedAt: c.CreatedAt, + ID: c.ID, + Name: c.Name, + AuthType: c.AuthType, + PublicKey: c.PublicKey, + Fingerprint: c.Fingerprint, + PersonaID: c.PersonaID, + CreatedAt: c.CreatedAt, } } diff --git a/server/models/models_notification.go b/server/models/models_notification.go index 7207505..d119962 100644 --- a/server/models/models_notification.go +++ b/server/models/models_notification.go @@ -34,6 +34,7 @@ const ( NotifTypeTaskCompleted = "task.completed" NotifTypeTaskFailed = "task.failed" NotifTypeTaskBudget = "task.budget_exceeded" + NotifTypeAnnouncement = "system.announcement" ) // Resource type constants for click-to-navigate. diff --git a/server/models/models_task.go b/server/models/models_task.go index af0fca4..7cc1dc4 100644 --- a/server/models/models_task.go +++ b/server/models/models_task.go @@ -7,7 +7,8 @@ import ( // Task is a scheduled, one-shot, or webhook-triggered job that creates a // service channel and runs a completion (prompt task), instantiates a -// workflow, or relays a payload without LLM involvement (action task). +// workflow, relays a payload without LLM involvement (action task), or +// executes a built-in Go function (system task). type Task struct { ID string `json:"id" db:"id"` OwnerID string `json:"owner_id" db:"owner_id"` @@ -17,7 +18,8 @@ type Task struct { Scope string `json:"scope" db:"scope"` // personal | team | global // What to run - TaskType string `json:"task_type" db:"task_type"` // prompt | workflow | action + TaskType string `json:"task_type" db:"task_type"` // prompt | workflow | action | system + SystemFunction string `json:"system_function,omitempty" db:"system_function"` PersonaID *string `json:"persona_id,omitempty" db:"persona_id"` ModelID string `json:"model_id" db:"model_id"` SystemPrompt string `json:"system_prompt" db:"system_prompt"` diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html index 6720296..2faab9d 100644 --- a/server/pages/templates/base.html +++ b/server/pages/templates/base.html @@ -103,6 +103,7 @@ + {{/* v0.25.0: Component scripts — available on all surfaces */}} diff --git a/server/pages/templates/surfaces/admin.html b/server/pages/templates/surfaces/admin.html index e0d8ada..e36f715 100644 --- a/server/pages/templates/surfaces/admin.html +++ b/server/pages/templates/surfaces/admin.html @@ -36,7 +36,7 @@ Routing - + System @@ -220,6 +220,7 @@ + + @@ -256,6 +258,7 @@ teams: () => typeof UI !== 'undefined' && UI.loadTeamsSettings?.(), workflows: () => typeof loadTeamWorkflows === 'function' && loadTeamWorkflows(), tasks: () => typeof _loadSettingsTasks === 'function' && _loadSettingsTasks(), + gitkeys: () => typeof _loadSettingsGitKeys === 'function' && _loadSettingsGitKeys(), }; // Populate App.policies and App.models from API. diff --git a/server/scheduler/executor.go b/server/scheduler/executor.go index c743c7b..f8599c1 100644 --- a/server/scheduler/executor.go +++ b/server/scheduler/executor.go @@ -51,6 +51,12 @@ func NewExecutor(stores store.Stores, vault *crypto.KeyResolver, hub *events.Hub func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.TaskRun, channelID string) { startTime := time.Now() + // v0.28.6: System tasks run a built-in Go function. No LLM, no provider, no channel. + if task.TaskType == "system" { + e.executeSystem(ctx, task, run, startTime) + return + } + // v0.28.0: Action tasks skip the LLM pipeline entirely. if task.TaskType == "action" { e.executeAction(ctx, task, run, channelID, startTime) @@ -259,6 +265,56 @@ func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.Ta } } +// executeSystem runs a built-in Go function from the system registry. +// No LLM, no provider resolution, no channel needed. +func (e *Executor) executeSystem(ctx context.Context, task models.Task, run *models.TaskRun, startTime time.Time) { + fn, ok := taskutil.GetSystemFunc(task.SystemFunction) + if !ok { + e.failRun(ctx, task, run, "unknown system function: "+task.SystemFunction) + return + } + + result, err := fn(ctx, e.stores) + + wallClock := int(time.Since(startTime).Seconds()) + status := "completed" + errMsg := "" + + if err != nil { + status = "failed" + errMsg = err.Error() + } + + // Store result as the run's output (tokens=0, tools=0 for system tasks) + _ = e.stores.Tasks.UpdateRun(ctx, run.ID, status, 0, 0, wallClock, errMsg) + _ = e.stores.Tasks.IncrementRunCount(ctx, task.ID) + + log.Printf("[executor] System task %s (%s → %s) → %s (wall=%ds, result=%s)", + task.ID, task.Name, task.SystemFunction, status, wallClock, truncate(result, 200)) + + e.notifyOwner(ctx, task, status, errMsg) + + // Outbound webhook with result + if task.WebhookURL != "" { + go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{ + TaskID: task.ID, + RunID: run.ID, + TaskName: task.Name, + Status: status, + CompletedAt: time.Now().UTC(), + Output: result, + Error: errMsg, + }) + } +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + // executeAction handles non-LLM action tasks. // Skips provider resolution and completion entirely. func (e *Executor) executeAction(ctx context.Context, task models.Task, run *models.TaskRun, channelID string, startTime time.Time) { diff --git a/server/scheduler/system_builtins.go b/server/scheduler/system_builtins.go new file mode 100644 index 0000000..c2b5dc6 --- /dev/null +++ b/server/scheduler/system_builtins.go @@ -0,0 +1,130 @@ +// Package scheduler — system_builtins.go +// +// v0.28.6: Built-in system functions registered at startup. +// These mirror the background goroutines in main.go but run as visible, +// scheduled, auditable tasks. The goroutines remain as fallback until +// system tasks are validated in production. +package scheduler + +import ( + "context" + "fmt" + "log" + "time" + + "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/store" + "git.gobha.me/xcaliber/chat-switchboard/taskutil" +) + +// RegisterBuiltins registers all built-in system functions. +// Called once from main.go at startup. +func RegisterBuiltins() { + taskutil.RegisterSystemFunc("session_cleanup", + "Remove expired anonymous visitor sessions", + sessionCleanup) + + taskutil.RegisterSystemFunc("staleness_check", + "Mark idle workflow instances as stale", + stalenessCheck) + + taskutil.RegisterSystemFunc("retention_sweep", + "Delete completed workflow instances past retention policy", + retentionSweep) + + taskutil.RegisterSystemFunc("health_prune", + "Prune provider health windows older than 7 days", + healthPrune) +} + +// ── session_cleanup ───────────────────────── + +func sessionCleanup(ctx context.Context, stores store.Stores) (string, error) { + if stores.Sessions == nil { + return "skipped: session store not available", nil + } + // Default: expire sessions older than 7 days + cutoff := time.Now().UTC().AddDate(0, 0, -7) + n, err := stores.Sessions.DeleteExpired(ctx, cutoff) + if err != nil { + return "", fmt.Errorf("session cleanup failed: %w", err) + } + msg := fmt.Sprintf("cleaned up %d expired sessions (cutoff: %s)", n, cutoff.Format(time.RFC3339)) + if n > 0 { + log.Printf("[system_task] %s", msg) + } + return msg, nil +} + +// ── staleness_check ───────────────────────── + +func stalenessCheck(ctx context.Context, stores store.Stores) (string, error) { + // Default: 48 hours idle → stale + cutoff := time.Now().UTC().Add(-48 * time.Hour) + res, err := database.DB.ExecContext(ctx, database.Q(` + UPDATE channels + SET workflow_status = 'stale' + WHERE type = 'workflow' + AND workflow_status = 'active' + AND last_activity_at < $1 + `), cutoff) + if err != nil { + return "", fmt.Errorf("staleness sweep failed: %w", err) + } + n, _ := res.RowsAffected() + msg := fmt.Sprintf("marked %d idle workflow instances as stale (cutoff: %s)", n, cutoff.Format(time.RFC3339)) + if n > 0 { + log.Printf("[system_task] %s", msg) + } + return msg, nil +} + +// ── retention_sweep ───────────────────────── + +func retentionSweep(ctx context.Context, stores store.Stores) (string, error) { + if database.IsSQLite() { + return "skipped: retention enforcement requires PostgreSQL (JSON operators)", nil + } + cutoff := time.Now().UTC().Add(-48 * time.Hour) + res, err := database.DB.ExecContext(ctx, database.Q(` + DELETE FROM channels + WHERE type = 'workflow' + AND workflow_status IN ('completed', 'archived') + AND workflow_id IS NOT NULL + AND last_activity_at < $1 + AND workflow_id IN ( + SELECT id FROM workflows + WHERE retention IS NOT NULL + AND retention->>'mode' = 'delete' + AND (retention->>'delete_after_days')::int > 0 + AND channels.last_activity_at < now() - ((retention->>'delete_after_days')::int || ' days')::interval + ) + `), cutoff) + if err != nil { + return "", fmt.Errorf("retention enforcement failed: %w", err) + } + n, _ := res.RowsAffected() + msg := fmt.Sprintf("deleted %d expired workflow instances (retention policy)", n) + if n > 0 { + log.Printf("[system_task] %s", msg) + } + return msg, nil +} + +// ── health_prune ──────────────────────────── + +func healthPrune(ctx context.Context, stores store.Stores) (string, error) { + cutoff := time.Now().UTC().Add(-7 * 24 * time.Hour) + res, err := database.DB.ExecContext(ctx, database.Q(` + DELETE FROM health_windows WHERE window_start < $1 + `), cutoff) + if err != nil { + return "", fmt.Errorf("health prune failed: %w", err) + } + n, _ := res.RowsAffected() + msg := fmt.Sprintf("pruned %d old health windows (cutoff: %s)", n, cutoff.Format(time.RFC3339)) + if n > 0 { + log.Printf("[system_task] %s", msg) + } + return msg, nil +} diff --git a/server/store/interfaces.go b/server/store/interfaces.go index d7401ca..d64c31d 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -183,6 +183,7 @@ type UserStore interface { List(ctx context.Context, opts ListOptions) ([]models.User, int, error) UpdateLastLogin(ctx context.Context, id string) error SetActive(ctx context.Context, id string, active bool) error + ListActiveUserIDs(ctx context.Context) ([]string, error) // Refresh tokens CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error diff --git a/server/store/postgres/git_credentials.go b/server/store/postgres/git_credentials.go index 7158783..4d569bc 100644 --- a/server/store/postgres/git_credentials.go +++ b/server/store/postgres/git_credentials.go @@ -13,29 +13,32 @@ type GitCredentialStore struct{} func (s *GitCredentialStore) Create(ctx context.Context, cred *models.GitCredential) error { if cred.ID != "" { return DB.QueryRowContext(ctx, ` - INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING created_at`, cred.ID, cred.UserID, cred.Name, cred.AuthType, cred.EncryptedData, cred.Nonce, + cred.PublicKey, cred.Fingerprint, cred.PersonaID, ).Scan(&cred.CreatedAt) } return DB.QueryRowContext(ctx, ` - INSERT INTO git_credentials (user_id, name, auth_type, encrypted_data, nonce) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO git_credentials (user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at`, cred.UserID, cred.Name, cred.AuthType, cred.EncryptedData, cred.Nonce, + cred.PublicKey, cred.Fingerprint, cred.PersonaID, ).Scan(&cred.ID, &cred.CreatedAt) } func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.GitCredential, error) { var c models.GitCredential err := DB.QueryRowContext(ctx, ` - SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at + SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at FROM git_credentials WHERE id = $1`, id, ).Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType, - &c.EncryptedData, &c.Nonce, &c.CreatedAt) + &c.EncryptedData, &c.Nonce, + &c.PublicKey, &c.Fingerprint, &c.PersonaID, &c.CreatedAt) if err != nil { return nil, err } @@ -44,7 +47,7 @@ func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.Gi func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error) { rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at + SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at FROM git_credentials WHERE user_id = $1 ORDER BY created_at DESC`, userID) if err != nil { return nil, err @@ -55,7 +58,8 @@ func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]m for rows.Next() { var c models.GitCredential if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType, - &c.EncryptedData, &c.Nonce, &c.CreatedAt); err != nil { + &c.EncryptedData, &c.Nonce, + &c.PublicKey, &c.Fingerprint, &c.PersonaID, &c.CreatedAt); err != nil { return nil, err } creds = append(creds, c) diff --git a/server/store/postgres/tasks.go b/server/store/postgres/tasks.go index 9000ed5..2e4d6c6 100644 --- a/server/store/postgres/tasks.go +++ b/server/store/postgres/tasks.go @@ -23,7 +23,8 @@ func NewTaskStore() *TaskStore { return &TaskStore{} } // (string, json.RawMessage). database/sql returns "unsupported Scan, // storing driver.Value type " for these without COALESCE. const taskColumns = `id, owner_id, team_id, name, description, scope, - task_type, persona_id, model_id, system_prompt, user_prompt, + task_type, COALESCE(system_function, '') AS system_function, + persona_id, model_id, system_prompt, user_prompt, workflow_id, COALESCE(tool_grants, '[]'::jsonb) AS tool_grants, schedule, timezone, is_active, COALESCE(trigger_token, '') AS trigger_token, @@ -40,7 +41,8 @@ const taskColumns = `id, owner_id, team_id, name, description, scope, func scanTask(scanner interface{ Scan(...interface{}) error }, t *models.Task) error { return scanner.Scan( &t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope, - &t.TaskType, &t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt, + &t.TaskType, &t.SystemFunction, + &t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt, &t.WorkflowID, &t.ToolGrants, &t.Schedule, &t.Timezone, &t.IsActive, &t.TriggerToken, &t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode, @@ -56,16 +58,18 @@ func (s *TaskStore) Create(ctx context.Context, t *models.Task) error { toolGrants := jsonOrNull(t.ToolGrants) return DB.QueryRowContext(ctx, ` INSERT INTO tasks (owner_id, team_id, name, description, scope, - task_type, persona_id, model_id, system_prompt, user_prompt, + task_type, system_function, + persona_id, model_id, system_prompt, user_prompt, workflow_id, tool_grants, schedule, timezone, is_active, trigger_token, max_tokens, max_tool_calls, max_wall_clock, output_mode, output_channel_id, webhook_url, webhook_secret, provider_config_id, notify_on_complete, notify_on_failure, next_run_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28) RETURNING id, created_at, updated_at`, t.OwnerID, t.TeamID, t.Name, t.Description, t.Scope, - t.TaskType, t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt, + t.TaskType, t.SystemFunction, + t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt, t.WorkflowID, toolGrants, t.Schedule, t.Timezone, t.IsActive, nilIfEmpty(t.TriggerToken), t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode, diff --git a/server/store/postgres/user.go b/server/store/postgres/user.go index 18d9d5d..3b5e024 100644 --- a/server/store/postgres/user.go +++ b/server/store/postgres/user.go @@ -122,6 +122,23 @@ func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error return err } +func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) { + rows, err := DB.QueryContext(ctx, "SELECT id FROM users WHERE is_active = true") + if err != nil { + return nil, err + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + // ── Refresh Tokens ────────────────────────── func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error { diff --git a/server/store/sqlite/git_credentials.go b/server/store/sqlite/git_credentials.go index 184582d..68243c2 100644 --- a/server/store/sqlite/git_credentials.go +++ b/server/store/sqlite/git_credentials.go @@ -18,11 +18,12 @@ func (s *GitCredentialStore) Create(ctx context.Context, cred *models.GitCredent cred.ID = uuid.NewString() } _, err := DB.ExecContext(ctx, ` - INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce, created_at) - VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`, + INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`, cred.ID, cred.UserID, cred.Name, cred.AuthType, base64.StdEncoding.EncodeToString(cred.EncryptedData), base64.StdEncoding.EncodeToString(cred.Nonce), + cred.PublicKey, cred.Fingerprint, cred.PersonaID, ) if err != nil { return err @@ -37,10 +38,10 @@ func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.Gi var c models.GitCredential var encData, nonce string err := DB.QueryRowContext(ctx, ` - SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at + SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at FROM git_credentials WHERE id = ?`, id, ).Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType, - &encData, &nonce, st(&c.CreatedAt)) + &encData, &nonce, &c.PublicKey, &c.Fingerprint, &c.PersonaID, st(&c.CreatedAt)) if err != nil { return nil, err } @@ -51,7 +52,7 @@ func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.Gi func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error) { rows, err := DB.QueryContext(ctx, ` - SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at + SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at FROM git_credentials WHERE user_id = ? ORDER BY created_at DESC`, userID) if err != nil { return nil, err @@ -63,7 +64,7 @@ func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]m var c models.GitCredential var encData, nonce string if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType, - &encData, &nonce, st(&c.CreatedAt)); err != nil { + &encData, &nonce, &c.PublicKey, &c.Fingerprint, &c.PersonaID, st(&c.CreatedAt)); err != nil { return nil, err } c.EncryptedData, _ = base64.StdEncoding.DecodeString(encData) diff --git a/server/store/sqlite/tasks.go b/server/store/sqlite/tasks.go index f09c37e..f199ef7 100644 --- a/server/store/sqlite/tasks.go +++ b/server/store/sqlite/tasks.go @@ -19,7 +19,8 @@ func NewTaskStore() *TaskStore { return &TaskStore{} } // COALESCE wraps nullable columns that scan into non-pointer Go types. // tool_grants uses COALESCE to '[]' so the string intermediate never sees NULL. const taskColumns = `id, owner_id, team_id, name, description, scope, - task_type, persona_id, model_id, system_prompt, user_prompt, + task_type, COALESCE(system_function, '') AS system_function, + persona_id, model_id, system_prompt, user_prompt, workflow_id, COALESCE(tool_grants, '[]') AS tool_grants, schedule, timezone, is_active, COALESCE(trigger_token, '') AS trigger_token, @@ -47,7 +48,8 @@ func scanTask(scanner interface{ Scan(...interface{}) error }, t *models.Task) e var isActive, notifyComplete, notifyFailure int err := scanner.Scan( &t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope, - &t.TaskType, &t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt, + &t.TaskType, &t.SystemFunction, + &t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt, &t.WorkflowID, &toolGrantsStr, &t.Schedule, &t.Timezone, &isActive, &t.TriggerToken, &t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode, @@ -74,15 +76,17 @@ func (s *TaskStore) Create(ctx context.Context, t *models.Task) error { t.UpdatedAt = now _, err := DB.ExecContext(ctx, ` INSERT INTO tasks (id, owner_id, team_id, name, description, scope, - task_type, persona_id, model_id, system_prompt, user_prompt, + task_type, system_function, + persona_id, model_id, system_prompt, user_prompt, workflow_id, tool_grants, schedule, timezone, is_active, trigger_token, max_tokens, max_tool_calls, max_wall_clock, output_mode, output_channel_id, webhook_url, webhook_secret, provider_config_id, notify_on_complete, notify_on_failure, next_run_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, t.ID, t.OwnerID, t.TeamID, t.Name, t.Description, t.Scope, - t.TaskType, t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt, + t.TaskType, t.SystemFunction, + t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt, t.WorkflowID, nullableJSON(t.ToolGrants), t.Schedule, t.Timezone, boolToInt(t.IsActive), nilIfEmpty(t.TriggerToken), t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode, diff --git a/server/store/sqlite/user.go b/server/store/sqlite/user.go index dc8f805..48e6bd0 100644 --- a/server/store/sqlite/user.go +++ b/server/store/sqlite/user.go @@ -128,6 +128,23 @@ func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error return err } +func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) { + rows, err := DB.QueryContext(ctx, "SELECT id FROM users WHERE is_active = 1") + if err != nil { + return nil, err + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + // ── Refresh Tokens ────────────────────────── func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error { diff --git a/server/taskutil/system_registry.go b/server/taskutil/system_registry.go new file mode 100644 index 0000000..b1ad22f --- /dev/null +++ b/server/taskutil/system_registry.go @@ -0,0 +1,89 @@ +// Package taskutil — system_registry.go +// +// v0.28.6: System task function registry. +// +// Built-in Go functions that run as auditable, scheduled tasks instead of +// ad-hoc goroutines. Each function receives a context, the full store set, +// and returns a structured result. The registry is permanent — system +// functions are not replaced by Starlark (v0.29.0). Core platform ops +// must not break from bad user code. +// +// Registration happens at init time from main.go. The executor calls +// Get() to look up a function by name at execution time. +package taskutil + +import ( + "context" + "fmt" + "sort" + "sync" + + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// SystemFunc is the signature for built-in system task functions. +// The function receives a context and the full store set. It returns +// a human-readable result string (logged in the task run) and an error. +type SystemFunc func(ctx context.Context, stores store.Stores) (string, error) + +// SystemFuncInfo describes a registered system function. +type SystemFuncInfo struct { + Name string `json:"name"` + Description string `json:"description"` +} + +var ( + registryMu sync.RWMutex + registry = make(map[string]registryEntry) +) + +type registryEntry struct { + fn SystemFunc + description string +} + +// RegisterSystemFunc registers a named system function. +// Call during init (before scheduler starts). Not goroutine-safe for writes. +func RegisterSystemFunc(name, description string, fn SystemFunc) { + registryMu.Lock() + defer registryMu.Unlock() + registry[name] = registryEntry{fn: fn, description: description} +} + +// GetSystemFunc returns a system function by name. +func GetSystemFunc(name string) (SystemFunc, bool) { + registryMu.RLock() + defer registryMu.RUnlock() + e, ok := registry[name] + if !ok { + return nil, false + } + return e.fn, true +} + +// ListSystemFuncs returns all registered system function names and descriptions. +func ListSystemFuncs() []SystemFuncInfo { + registryMu.RLock() + defer registryMu.RUnlock() + result := make([]SystemFuncInfo, 0, len(registry)) + for name, e := range registry { + result = append(result, SystemFuncInfo{Name: name, Description: e.description}) + } + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) + return result +} + +// ValidateSystemFunc returns an error if the function name is not registered. +func ValidateSystemFunc(name string) error { + registryMu.RLock() + defer registryMu.RUnlock() + if _, ok := registry[name]; !ok { + names := make([]string, 0, len(registry)) + for n := range registry { + names = append(names, n) + } + sort.Strings(names) + return fmt.Errorf("unknown system function %q (available: %v)", name, names) + } + return nil +} diff --git a/src/js/broadcast-admin.js b/src/js/broadcast-admin.js new file mode 100644 index 0000000..18cbecc --- /dev/null +++ b/src/js/broadcast-admin.js @@ -0,0 +1,96 @@ +// ========================================== +// Chat Switchboard — Broadcast Admin Panel +// ========================================== +// Admin section for composing and sending system announcements. +// Registered as ADMIN_LOADERS.broadcast in ui-admin.js. + + var SCAFFOLD_HTML = + '
' + + '

' + + 'Send a system announcement to all active users. Notifications appear in their ' + + 'bell menu and optionally via email (based on user preferences).' + + '

' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '' + + '' + + '
'; + + sb.register('_loadAdminBroadcast', async function () { + var body = document.getElementById('adminDynamic') || document.getElementById('adminContentBody'); + if (!body) return; + body.innerHTML = SCAFFOLD_HTML; + + var sendBtn = document.getElementById('broadcastSendBtn'); + var resultEl = document.getElementById('broadcastResult'); + + sendBtn.addEventListener('click', async function () { + var title = document.getElementById('broadcastTitle').value.trim(); + var message = document.getElementById('broadcastMessage').value.trim(); + var level = document.getElementById('broadcastLevel').value; + + if (!title) { UI.toast('Title is required', 'warning'); return; } + if (!message) { UI.toast('Message is required', 'warning'); return; } + + // Confirmation + var ok = await UI.confirm( + 'Send broadcast to all active users?\n\n' + + 'Level: ' + level + '\n' + + 'Title: ' + title + ); + if (!ok) return; + + sendBtn.disabled = true; + sendBtn.textContent = 'Sending…'; + resultEl.style.display = 'none'; + + try { + var resp = await API.post('/admin/notifications/broadcast', { + title: title, + message: message, + level: level, + }); + + var count = resp.count || 0; + resultEl.style.display = 'block'; + resultEl.className = 'admin-success'; + resultEl.innerHTML = + ' ' + + 'Broadcast sent to ' + count + ' user' + (count !== 1 ? 's' : '') + '.'; + + // Clear form + document.getElementById('broadcastTitle').value = ''; + document.getElementById('broadcastMessage').value = ''; + document.getElementById('broadcastLevel').value = 'info'; + + UI.toast('Broadcast sent to ' + count + ' users', 'success'); + } catch (err) { + resultEl.style.display = 'block'; + resultEl.className = 'admin-error'; + resultEl.innerHTML = + ' ' + + 'Failed: ' + (err.message || err); + UI.toast('Broadcast failed', 'error'); + } finally { + sendBtn.disabled = false; + sendBtn.textContent = ' Send Broadcast'; + } + }); + }); diff --git a/src/js/chat.js b/src/js/chat.js index 3c316b6..65a98ac 100644 --- a/src/js/chat.js +++ b/src/js/chat.js @@ -236,6 +236,8 @@ async function loadChats() { async function selectChat(chatId) { clearStaged(); // Discard any staged files from previous chat + // v0.28.6: Reset virtual scroll when switching conversations + if (typeof VirtualScroll !== 'undefined') VirtualScroll.destroy(); const chat = App.chats.find(c => c.id === chatId); App.setActive(chatId, chat?.type || 'direct'); try { sessionStorage.setItem('cs-active-conversation', JSON.stringify(App.activeConversation)); } catch (_) {} @@ -418,6 +420,7 @@ function _cleanStaleChatModel(chatId) { async function newChat() { clearStaged(); // Discard any staged files + if (typeof VirtualScroll !== 'undefined') VirtualScroll.destroy(); App.setActive(null); UI.renderChatList(); UI.renderChannelsSection(); diff --git a/src/js/git-credentials-ui.js b/src/js/git-credentials-ui.js new file mode 100644 index 0000000..8aaa3e3 --- /dev/null +++ b/src/js/git-credentials-ui.js @@ -0,0 +1,180 @@ +// ========================================== +// Chat Switchboard — Git Credentials Settings +// ========================================== +// Settings section for managing SSH keys used with git workspaces. +// Keys are generated server-side — private keys never leave the server. +// Users copy the public key into their repo host (GitHub, Gitea, etc.). +// Optional persona binding enables per-persona commit attribution. + + var SCAFFOLD_HTML = + '
' + + '

' + + 'SSH keys for git operations. Keys are generated on the server — the private key ' + + 'is vault-encrypted and never exposed. Copy the public key into your git host.' + + '

' + + '
' + + '

SSH Keys

' + + '' + + '
' + + '
' + + '' + + '
' + + '
' + + '' + + 'Security' + + '
' + + '

' + + 'Private keys are encrypted with your personal vault key. They are used server-side ' + + 'for git push/pull operations and cannot be downloaded or viewed.' + + '

' + + '
' + + '
'; + + // ── Generate key dialog ── + var GENERATE_HTML = + '
' + + '
' + + '' + + '' + + '
' + + '' + + '
'; + + 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 ? '' + esc(c.fingerprint) + '' : ''; + var persona = c.persona_id ? 'persona' : ''; + return '
' + + '
' + + '
' + esc(c.name) + ' ' + persona + '
' + + '
' + + '' + esc(c.auth_type) + '' + + fp + + '' + dateStr + '' + + '
' + + '
' + + (c.public_key ? '' : '') + + '' + + '
'; + }).join(''); + } catch (err) { + listEl.innerHTML = '
Failed to load credentials
'; + } + } + + function showPublicKey(cred) { + if (!cred.public_key) return; + UI.modal('Public Key — ' + cred.name, + '

Add this key to your git host (GitHub → Settings → SSH Keys):

' + + '' + + '

Fingerprint: ' + esc(cred.fingerprint) + '

', + [{ label: 'Done', variant: 'primary' }] + ); + } + + // Register global handlers for onclick + window._gitKeyCopy = async function (id) { + try { + var d = await API.get('/git-credentials/' + id + '/public-key'); + await navigator.clipboard.writeText(d.public_key); + UI.toast('Public key copied to clipboard', 'success'); + } catch (err) { + UI.toast('Failed to copy: ' + (err.message || err), 'error'); + } + }; + + window._gitKeyDelete = async function (id, name) { + var ok = await UI.confirm('Delete SSH key "' + name + '"?\n\nWorkspaces using this key will lose git access until reassigned.'); + if (!ok) return; + try { + await API.del('/git-credentials/' + id); + UI.toast('Key deleted', 'success'); + await refreshList(); + } catch (err) { + UI.toast('Delete failed: ' + (err.message || err), 'error'); + } + }; + } + + function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; } + + sb.register('_loadSettingsGitKeys', _loadSettingsGitKeys); diff --git a/src/js/task-admin.js b/src/js/task-admin.js index 033185d..d79dbfa 100644 --- a/src/js/task-admin.js +++ b/src/js/task-admin.js @@ -9,6 +9,7 @@ '
' + '' + '' + + '' + '
' + '
' + '
' + @@ -47,6 +48,28 @@ '' + '
' + '
' + + '
' + + ''; 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 @@ : 'paused'; var scope = '' + t.scope + ''; var type = '' + t.task_type + ''; - 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 + ? '' + escapeHtml(t.system_function) + '' : ''; + 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 = ''; + } return '
' + '
' + @@ -100,9 +131,10 @@ escapeHtml(schedule) + ' \u00b7 ' + t.run_count + ' runs \u00b7 last: ' + lastRun + '
' + '
' + - '
' + type + scope + active + '
' + + '
' + type + funcBadge + scope + active + '
' + '
next: ' + nextRun + '
' + '
' + + triggerBtn + '' + '' + '' + @@ -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 = ''; + _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); diff --git a/src/js/task-settings.js b/src/js/task-settings.js index 7a4f889..8359c41 100644 --- a/src/js/task-settings.js +++ b/src/js/task-settings.js @@ -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 ? 'active' : 'paused'; var teamBadge = t._source === 'team' ? '' + esc(t._teamName || 'team') + '' : ''; - 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 = ''; + } return '
' + '
' + @@ -89,6 +94,7 @@ '
' + '
' + teamBadge + status + + triggerBtn + '' + '' + '' + @@ -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 = '

Failed to load tasks: ' + esc(err.message) + '

'; } @@ -164,6 +180,17 @@ '
' + '
' + '
' + + '
' + + '' + + '

Used to sign outbound webhook payloads. Leave blank for auto-generated secret.

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

POST to this URL to trigger the task. ' + + 'You can use this in CI pipelines, cron jobs, or as another task\\u2019s webhook URL for chaining.

' + + '
' + + '' + + '' + + '
' + + '

The request body is passed to the task as trigger_payload. ' + + 'For task-to-task chaining: set Task A\\u2019s webhook_url to Task B\\u2019s trigger URL.

', + [{ label: 'Done', variant: 'primary' }] + ); + setTimeout(function() { + var copyBtn = document.getElementById('_copyTriggerBtn'); + if (copyBtn) copyBtn.addEventListener('click', function() { + var input = document.getElementById('_triggerURLInput'); + if (input) { navigator.clipboard.writeText(input.value); UI.toast('Copied', 'success'); } + }); + }, 100); + } + loadTaskList(); - // Refresh sidebar if (typeof TaskSidebar !== 'undefined') TaskSidebar.refresh(); } catch (err) { UI.toast(err.message || 'Failed to create task', 'error'); diff --git a/src/js/ui-admin.js b/src/js/ui-admin.js index fff1413..fd6b7cb 100644 --- a/src/js/ui-admin.js +++ b/src/js/ui-admin.js @@ -11,7 +11,7 @@ const ADMIN_SECTIONS = { ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'], workflows: ['workflows', 'tasks'], routing: ['health', 'routing', 'capabilities'], - system: ['settings', 'storage', 'extensions', 'channels', 'surfaces'], + system: ['settings', 'storage', 'extensions', 'channels', 'surfaces', 'broadcast'], monitoring: ['usage', 'audit', 'stats'], }; @@ -20,7 +20,7 @@ const ADMIN_LABELS = { providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory', health: 'Health', routing: 'Routing', capabilities: 'Capabilities', workflows: 'Workflows', tasks: 'Tasks', - settings: 'Settings', storage: 'Storage', extensions: 'Extensions', channels: 'Channels', surfaces: 'Surfaces', + settings: 'Settings', storage: 'Storage', extensions: 'Extensions', channels: 'Channels', surfaces: 'Surfaces', broadcast: 'Broadcast', usage: 'Usage', audit: 'Audit', stats: 'Stats', }; @@ -54,6 +54,7 @@ const ADMIN_LOADERS = { channels: () => UI.loadAdminChannels(), workflows: () => typeof _loadAdminWorkflows === 'function' ? _loadAdminWorkflows() : null, tasks: () => typeof _loadAdminTasks === 'function' ? _loadAdminTasks() : null, + broadcast: () => typeof _loadAdminBroadcast === 'function' ? _loadAdminBroadcast() : null, }; // Find which category a section belongs to diff --git a/src/js/ui-core.js b/src/js/ui-core.js index 27a8852..bb67ec3 100644 --- a/src/js/ui-core.js +++ b/src/js/ui-core.js @@ -709,6 +709,7 @@ const UI = { renderMessages(messages) { const el = document.getElementById('chatMessages'); if (!messages || messages.length === 0) { + if (typeof VirtualScroll !== 'undefined') VirtualScroll.destroy(); this.showEmptyState(); return; } @@ -719,7 +720,30 @@ const UI = { if (_isSummaryMessage(messages[i])) summaryIdx = i; } - // Render: if summary exists, show collapsed-history toggle + summary + messages after + // v0.28.6: Virtual scroll for long conversations + if (typeof VirtualScroll !== 'undefined' && el) { + // Lazy bind — only once per container + if (!VirtualScroll._container) { + const self = this; + const postRender = (container) => { + if (typeof runExtensionPostRender === 'function') runExtensionPostRender(container); + if (typeof loadAuthImages === 'function') loadAuthImages(container); + }; + VirtualScroll.bind( + el, + (msg, idx, dimmed) => self._messageHTML(msg, idx, dimmed), + (msg) => self._summaryHTML(msg), + postRender + ); + } + if (VirtualScroll.setMessages(messages, summaryIdx)) { + this._scrollToBottom(true); + return; // VirtualScroll handled it + } + // Falls through for short conversations + } + + // Original path for short conversations let html = ''; if (summaryIdx >= 0) { const hiddenCount = messages.filter((m, i) => i < summaryIdx && m.role !== 'system').length; diff --git a/src/js/virtual-scroll.js b/src/js/virtual-scroll.js new file mode 100644 index 0000000..4c3225d --- /dev/null +++ b/src/js/virtual-scroll.js @@ -0,0 +1,277 @@ +// ========================================== +// Chat Switchboard — Virtual Scroll +// ========================================== +// Viewport-windowed message rendering for long conversations. +// Renders a window of messages around the viewport. As the user +// scrolls, older/newer chunks are rendered on demand and distant +// messages are removed from the DOM to keep memory bounded. +// +// Design: sentinel-based (IntersectionObserver on top/bottom markers) +// rather than scroll-listener based. No jank, no debounce. +// +// Integration: wraps UI._messageHTML() — no rendering duplication. +// The container element (#chatMessages) is unchanged. +// +// Streaming: the actively-streaming message is always in DOM at the +// bottom, managed by streamResponse(). VirtualScroll leaves it alone. + + const WINDOW_SIZE = 80; // max messages in DOM at once + const BUFFER = 20; // render this many beyond viewport edge + const PREPEND_CHUNK = 40; // load this many when scrolling up + const LOAD_MORE_THRESHOLD = 100; // only activate for conversations this long + + class VirtualScroll { + constructor() { + this._messages = []; // full message array (data, not DOM) + this._renderFn = null; // (msg, index, dimmed) => html string + this._summaryFn = null; // (msg) => html string + this._postRenderFn = null; // (container) => void + this._container = null; + this._observer = null; + this._topSentinel = null; + this._renderedRange = { start: 0, end: 0 }; // indices into _messages + this._enabled = false; + this._scrollLock = false; // prevent observer re-entry during DOM mutation + this._summaryIdx = -1; + } + + // ── Configuration ──────────────────────── + + /** + * Bind to a container and rendering functions. + * @param {HTMLElement} container - the #chatMessages element + * @param {Function} renderFn - (msg, index, dimmed?) => html string + * @param {Function} summaryFn - (msg) => html string + * @param {Function} postRenderFn - (container) => void (extensions, images) + */ + bind(container, renderFn, summaryFn, postRenderFn) { + this._container = container; + this._renderFn = renderFn; + this._summaryFn = summaryFn; + this._postRenderFn = postRenderFn; + } + + // ── Public API ─────────────────────────── + + /** + * Set messages and render. Called by renderMessages(). + * For short conversations, returns false (caller should use + * the original innerHTML path). For long ones, renders the + * tail window and returns true. + */ + setMessages(messages, summaryIdx) { + this._messages = messages; + this._summaryIdx = summaryIdx; + + if (!this._container || !this._renderFn) return false; + + // Only virtualize long conversations + const visible = messages.filter(m => m.role !== 'system'); + if (visible.length < LOAD_MORE_THRESHOLD) { + this._teardown(); + this._enabled = false; + return false; + } + + this._enabled = true; + this._renderTail(); + return true; + } + + /** + * Whether virtual scroll is currently active. + */ + get active() { return this._enabled; } + + /** + * Clean up observers. Call when switching conversations. + */ + destroy() { + this._teardown(); + this._messages = []; + this._enabled = false; + } + + // ── Internal: Render ───────────────────── + + /** + * Render the tail (most recent) window of messages. + * This is the initial render — user sees the latest messages. + */ + _renderTail() { + const msgs = this._filteredMessages(); + const total = msgs.length; + const start = Math.max(0, total - WINDOW_SIZE); + const end = total; + + this._renderedRange = { start, end }; + this._buildDOM(msgs, start, end); + } + + /** + * Prepend older messages when user scrolls to top. + */ + _prependChunk() { + if (this._scrollLock) return; + const msgs = this._filteredMessages(); + const { start } = this._renderedRange; + if (start <= 0) return; // already showing all + + this._scrollLock = true; + + const newStart = Math.max(0, start - PREPEND_CHUNK); + const container = this._container; + + // Remember scroll position to restore after prepend + const scrollBottom = container.scrollHeight - container.scrollTop; + + // Build HTML for the new chunk + let html = ''; + for (let i = newStart; i < start; i++) { + html += this._renderOne(msgs[i], i); + } + + // Insert after top sentinel, before existing messages + const sentinel = this._topSentinel; + if (sentinel) { + const frag = document.createRange().createContextualFragment(html); + sentinel.after(frag); + } + + // Trim bottom if DOM is too large + const totalRendered = this._renderedRange.end - newStart; + if (totalRendered > WINDOW_SIZE + BUFFER) { + const excess = totalRendered - WINDOW_SIZE; + this._removeFromBottom(excess); + this._renderedRange.end -= excess; + } + + this._renderedRange.start = newStart; + + // Restore scroll position (prevent jump) + requestAnimationFrame(() => { + container.scrollTop = container.scrollHeight - scrollBottom; + if (this._postRenderFn) this._postRenderFn(container); + this._scrollLock = false; + this._updateSentinelState(); + }); + } + + // ── Internal: DOM ──────────────────────── + + _buildDOM(msgs, start, end) { + const container = this._container; + + let html = ''; + + // Top sentinel (triggers prepend on intersection) + html += '
'; + + // "Load more" indicator + if (start > 0) { + html += '
' + + '' + + start + ' earlier messages
'; + } + + // 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); diff --git a/surfaces/icd-test-runner/js/crud/notifications.js b/surfaces/icd-test-runner/js/crud/notifications.js index f8171be..e9bac2f 100644 --- a/surfaces/icd-test-runner/js/crud/notifications.js +++ b/surfaces/icd-test-runner/js/crud/notifications.js @@ -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'); + }); + }; })(); diff --git a/surfaces/icd-test-runner/js/crud/tasks.js b/surfaces/icd-test-runner/js/crud/tasks.js index d2d5a2d..7776f19 100644 --- a/surfaces/icd-test-runner/js/crud/tasks.js +++ b/surfaces/icd-test-runner/js/crud/tasks.js @@ -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'); + }); + }; })(); diff --git a/surfaces/icd-test-runner/js/crud/workspaces.js b/surfaces/icd-test-runner/js/crud/workspaces.js index 7a82505..e7a432c 100644 --- a/surfaces/icd-test-runner/js/crud/workspaces.js +++ b/surfaces/icd-test-runner/js/crud/workspaces.js @@ -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 () { diff --git a/surfaces/icd-test-runner/js/framework.js b/surfaces/icd-test-runner/js/framework.js index a07c917..4def217 100644 --- a/surfaces/icd-test-runner/js/framework.js +++ b/surfaces/icd-test-runner/js/framework.js @@ -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' };