Changeset 0.28.6 (#192)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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