Changeset 0.28.6 (#192)
This commit is contained in:
@@ -217,7 +217,7 @@ CREATE TABLE IF NOT EXISTS user_model_settings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
hidden BOOLEAN DEFAULT false,
|
||||
preferred_temperature FLOAT,
|
||||
preferred_max_tokens INT,
|
||||
|
||||
@@ -107,6 +107,9 @@ CREATE TABLE IF NOT EXISTS git_credentials (
|
||||
auth_type TEXT NOT NULL DEFAULT 'https_pat',
|
||||
encrypted_data BYTEA NOT NULL DEFAULT '',
|
||||
nonce BYTEA NOT NULL DEFAULT '',
|
||||
public_key TEXT NOT NULL DEFAULT '',
|
||||
fingerprint TEXT NOT NULL DEFAULT '',
|
||||
persona_id UUID REFERENCES personas(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
|
||||
-- What to run
|
||||
task_type TEXT NOT NULL DEFAULT 'prompt'
|
||||
CHECK (task_type IN ('prompt', 'workflow', 'action')),
|
||||
CHECK (task_type IN ('prompt', 'workflow', 'action', 'system')),
|
||||
system_function TEXT DEFAULT '',
|
||||
persona_id UUID REFERENCES personas(id) ON DELETE SET NULL,
|
||||
model_id TEXT,
|
||||
system_prompt TEXT DEFAULT '',
|
||||
|
||||
@@ -133,7 +133,7 @@ CREATE TABLE IF NOT EXISTS user_model_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
provider_config_id TEXT NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
hidden INTEGER DEFAULT 0,
|
||||
preferred_temperature REAL,
|
||||
preferred_max_tokens INTEGER,
|
||||
|
||||
@@ -60,6 +60,9 @@ CREATE TABLE IF NOT EXISTS git_credentials (
|
||||
auth_type TEXT NOT NULL DEFAULT 'https_pat',
|
||||
encrypted_data BLOB NOT NULL DEFAULT '',
|
||||
nonce BLOB NOT NULL DEFAULT '',
|
||||
public_key TEXT NOT NULL DEFAULT '',
|
||||
fingerprint TEXT NOT NULL DEFAULT '',
|
||||
persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
description TEXT DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'personal',
|
||||
task_type TEXT NOT NULL DEFAULT 'prompt',
|
||||
system_function TEXT DEFAULT '',
|
||||
persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
|
||||
model_id TEXT,
|
||||
system_prompt TEXT DEFAULT '',
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
@@ -334,3 +339,98 @@ func (h *GitCredentialHandler) Delete(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// Generate creates a new ED25519 SSH keypair server-side.
|
||||
// The private key is vault-encrypted before storage. Only the public key
|
||||
// and fingerprint are returned — the private key never leaves the server.
|
||||
//
|
||||
// POST /git-credentials/generate
|
||||
func (h *GitCredentialHandler) Generate(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
PersonaID *string `json:"persona_id,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate ED25519 keypair
|
||||
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "key generation failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Marshal public key to authorized_keys format
|
||||
sshPub, err := ssh.NewPublicKey(pubKey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "public key encoding failed"})
|
||||
return
|
||||
}
|
||||
authorizedKey := string(ssh.MarshalAuthorizedKey(sshPub))
|
||||
|
||||
// Fingerprint (SHA256)
|
||||
fingerprint := ssh.FingerprintSHA256(sshPub)
|
||||
|
||||
// Marshal private key to OpenSSH PEM format
|
||||
privPEM, err := ssh.MarshalPrivateKey(privKey, "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "private key encoding failed"})
|
||||
return
|
||||
}
|
||||
privPEMBytes := pem.EncodeToMemory(privPEM)
|
||||
|
||||
// Encrypt private key via vault
|
||||
credData := map[string]string{"private_key": string(privPEMBytes)}
|
||||
plaintext, _ := json.Marshal(credData)
|
||||
|
||||
ciphertext, nonce, err := h.vault.EncryptForScope(string(plaintext), "global", userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "encryption failed"})
|
||||
return
|
||||
}
|
||||
|
||||
cred := &models.GitCredential{
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
AuthType: "ssh_key",
|
||||
EncryptedData: ciphertext,
|
||||
Nonce: nonce,
|
||||
PublicKey: authorizedKey,
|
||||
Fingerprint: fingerprint,
|
||||
PersonaID: req.PersonaID,
|
||||
}
|
||||
|
||||
if err := h.stores.GitCredentials.Create(c.Request.Context(), cred); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, cred.Summary())
|
||||
}
|
||||
|
||||
// GetPublicKey returns the public key for a credential (for copy-to-clipboard).
|
||||
//
|
||||
// GET /git-credentials/:id/public-key
|
||||
func (h *GitCredentialHandler) GetPublicKey(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
credID := c.Param("id")
|
||||
|
||||
cred, err := h.stores.GitCredentials.GetByID(c.Request.Context(), credID)
|
||||
if err != nil || cred.UserID != userID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if cred.PublicKey == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no public key for this credential type"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"public_key": cred.PublicKey,
|
||||
"fingerprint": cred.Fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
281
server/handlers/git_credentials_test.go
Normal file
281
server/handlers/git_credentials_test.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
|
||||
)
|
||||
|
||||
// ── Git Credentials Test Harness ──────────
|
||||
|
||||
type gitCredHarness struct {
|
||||
*testHarness
|
||||
userToken string
|
||||
userID string
|
||||
user2Token string
|
||||
user2ID string
|
||||
}
|
||||
|
||||
func setupGitCredHarness(t *testing.T) *gitCredHarness {
|
||||
t.Helper()
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTSecret: testJWTSecret,
|
||||
BasePath: "",
|
||||
}
|
||||
|
||||
var stores store.Stores
|
||||
if database.IsSQLite() {
|
||||
stores = sqlite.NewStores(database.TestDB)
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
|
||||
// Create a test vault with a static env key (32 bytes for AES-256)
|
||||
envKey := []byte("test-encryption-key-32-bytes!!")
|
||||
for len(envKey) < 32 {
|
||||
envKey = append(envKey, '0')
|
||||
}
|
||||
envKey = envKey[:32]
|
||||
vault := crypto.NewKeyResolver(envKey, nil)
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
|
||||
gitCredH := NewGitCredentialHandler(stores, vault)
|
||||
protected.POST("/git-credentials", gitCredH.Create)
|
||||
protected.POST("/git-credentials/generate", gitCredH.Generate)
|
||||
protected.GET("/git-credentials", gitCredH.List)
|
||||
protected.GET("/git-credentials/:id/public-key", gitCredH.GetPublicKey)
|
||||
protected.DELETE("/git-credentials/:id", gitCredH.Delete)
|
||||
|
||||
// Seed users
|
||||
userID := database.SeedTestUser(t, "gituser", "gituser@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
userToken := makeToken(userID, "gituser@test.com", "user")
|
||||
|
||||
user2ID := database.SeedTestUser(t, "gituser2", "gituser2@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), user2ID)
|
||||
user2Token := makeToken(user2ID, "gituser2@test.com", "user")
|
||||
|
||||
return &gitCredHarness{
|
||||
testHarness: &testHarness{router: r, t: t},
|
||||
userToken: userToken,
|
||||
userID: userID,
|
||||
user2Token: user2Token,
|
||||
user2ID: user2ID,
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /git-credentials — empty state ───
|
||||
|
||||
func TestGitCreds_List_Empty(t *testing.T) {
|
||||
h := setupGitCredHarness(t)
|
||||
|
||||
resp := h.request("GET", "/api/v1/git-credentials", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
data, ok := body["data"]
|
||||
if !ok {
|
||||
t.Fatal("response must have 'data' key")
|
||||
}
|
||||
arr := data.([]interface{})
|
||||
if len(arr) != 0 {
|
||||
t.Fatalf("expected empty array, got %d items", len(arr))
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /git-credentials/generate ───────
|
||||
|
||||
func TestGitCreds_Generate(t *testing.T) {
|
||||
h := setupGitCredHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
|
||||
"name": "Test Key",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("generate: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var cred map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&cred)
|
||||
|
||||
// Must have public_key and fingerprint
|
||||
pubKey, _ := cred["public_key"].(string)
|
||||
fp, _ := cred["fingerprint"].(string)
|
||||
if pubKey == "" {
|
||||
t.Error("public_key should be non-empty")
|
||||
}
|
||||
if fp == "" {
|
||||
t.Error("fingerprint should be non-empty")
|
||||
}
|
||||
if cred["auth_type"] != "ssh_key" {
|
||||
t.Errorf("auth_type: got %v, want ssh_key", cred["auth_type"])
|
||||
}
|
||||
if cred["name"] != "Test Key" {
|
||||
t.Errorf("name: got %v", cred["name"])
|
||||
}
|
||||
|
||||
// Must NOT have encrypted data
|
||||
if _, has := cred["encrypted_data"]; has {
|
||||
t.Error("encrypted_data must not appear in response")
|
||||
}
|
||||
if _, has := cred["nonce"]; has {
|
||||
t.Error("nonce must not appear in response")
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /git-credentials/:id/public-key ──
|
||||
|
||||
func TestGitCreds_GetPublicKey(t *testing.T) {
|
||||
h := setupGitCredHarness(t)
|
||||
|
||||
// Generate a key first
|
||||
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
|
||||
"name": "PK Test",
|
||||
})
|
||||
var cred map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&cred)
|
||||
id := cred["id"].(string)
|
||||
origPK := cred["public_key"].(string)
|
||||
|
||||
// Retrieve public key
|
||||
resp = h.request("GET", "/api/v1/git-credentials/"+id+"/public-key", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("get public-key: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var pkBody map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&pkBody)
|
||||
if pkBody["public_key"] != origPK {
|
||||
t.Errorf("public key mismatch")
|
||||
}
|
||||
if pkBody["fingerprint"] == nil || pkBody["fingerprint"] == "" {
|
||||
t.Error("fingerprint should be present")
|
||||
}
|
||||
}
|
||||
|
||||
// ── List after generate ──────────────────
|
||||
|
||||
func TestGitCreds_ListAfterGenerate(t *testing.T) {
|
||||
h := setupGitCredHarness(t)
|
||||
|
||||
h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
|
||||
"name": "Key A",
|
||||
})
|
||||
h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
|
||||
"name": "Key B",
|
||||
})
|
||||
|
||||
resp := h.request("GET", "/api/v1/git-credentials", h.userToken, nil)
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
arr := body["data"].([]interface{})
|
||||
if len(arr) != 2 {
|
||||
t.Fatalf("expected 2 keys, got %d", len(arr))
|
||||
}
|
||||
|
||||
// Verify summaries have public_key + fingerprint
|
||||
for _, raw := range arr {
|
||||
item := raw.(map[string]interface{})
|
||||
if item["public_key"] == nil || item["public_key"] == "" {
|
||||
t.Error("list item should have public_key")
|
||||
}
|
||||
if item["fingerprint"] == nil || item["fingerprint"] == "" {
|
||||
t.Error("list item should have fingerprint")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ───────────────────────────────
|
||||
|
||||
func TestGitCreds_Delete(t *testing.T) {
|
||||
h := setupGitCredHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
|
||||
"name": "Delete Me",
|
||||
})
|
||||
var cred map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&cred)
|
||||
id := cred["id"].(string)
|
||||
|
||||
// Delete
|
||||
resp = h.request("DELETE", "/api/v1/git-credentials/"+id, h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("delete: got %d", resp.Code)
|
||||
}
|
||||
|
||||
// Verify gone
|
||||
resp = h.request("GET", "/api/v1/git-credentials", h.userToken, nil)
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
arr := body["data"].([]interface{})
|
||||
if len(arr) != 0 {
|
||||
t.Fatalf("expected 0 keys after delete, got %d", len(arr))
|
||||
}
|
||||
}
|
||||
|
||||
// ── User isolation ───────────────────────
|
||||
|
||||
func TestGitCreds_UserIsolation(t *testing.T) {
|
||||
h := setupGitCredHarness(t)
|
||||
|
||||
// User 1 generates a key
|
||||
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
|
||||
"name": "User1 Key",
|
||||
})
|
||||
var cred map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&cred)
|
||||
id := cred["id"].(string)
|
||||
|
||||
// User 2 should see empty list
|
||||
resp = h.request("GET", "/api/v1/git-credentials", h.user2Token, nil)
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
arr := body["data"].([]interface{})
|
||||
if len(arr) != 0 {
|
||||
t.Fatalf("user2 should see 0 keys, got %d", len(arr))
|
||||
}
|
||||
|
||||
// User 2 should not be able to get user1's public key
|
||||
resp = h.request("GET", "/api/v1/git-credentials/"+id+"/public-key", h.user2Token, nil)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-user public-key: want 404, got %d", resp.Code)
|
||||
}
|
||||
|
||||
// User 2 should not be able to delete user1's key
|
||||
resp = h.request("DELETE", "/api/v1/git-credentials/"+id, h.user2Token, nil)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-user delete: want 404, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation ───────────────────────────
|
||||
|
||||
func TestGitCreds_Generate_MissingName(t *testing.T) {
|
||||
h := setupGitCredHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing name: want 400, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
363
server/handlers/model_prefs_test.go
Normal file
363
server/handlers/model_prefs_test.go
Normal file
@@ -0,0 +1,363 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
|
||||
)
|
||||
|
||||
// ── Model Prefs Test Harness ──────────────
|
||||
|
||||
type modelPrefsHarness struct {
|
||||
*testHarness
|
||||
userToken string
|
||||
userID string
|
||||
user2Token string
|
||||
user2ID string
|
||||
configID string // seeded provider_config for preference tests
|
||||
}
|
||||
|
||||
func setupModelPrefsHarness(t *testing.T) *modelPrefsHarness {
|
||||
t.Helper()
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTSecret: testJWTSecret,
|
||||
BasePath: "",
|
||||
}
|
||||
|
||||
var stores store.Stores
|
||||
if database.IsSQLite() {
|
||||
stores = sqlite.NewStores(database.TestDB)
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
|
||||
modelPrefs := NewModelPrefsHandler(stores)
|
||||
protected.GET("/models/preferences", modelPrefs.GetPreferences)
|
||||
protected.PUT("/models/preferences", modelPrefs.SetPreference)
|
||||
protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
|
||||
|
||||
// Seed two users
|
||||
userID := database.SeedTestUser(t, "prefuser", "prefuser@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
userToken := makeToken(userID, "prefuser@test.com", "user")
|
||||
|
||||
user2ID := database.SeedTestUser(t, "prefuser2", "prefuser2@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), user2ID)
|
||||
user2Token := makeToken(user2ID, "prefuser2@test.com", "user")
|
||||
|
||||
// Seed a provider config for preference targets
|
||||
configID := uuid.New().String()
|
||||
if database.IsSQLite() {
|
||||
database.TestDB.Exec(`
|
||||
INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope)
|
||||
VALUES (?, 'global', 'Test Provider', 'openai', 'https://api.openai.com/v1', 'global')`,
|
||||
configID)
|
||||
} else {
|
||||
database.TestDB.Exec(`
|
||||
INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope)
|
||||
VALUES ($1, 'global', 'Test Provider', 'openai', 'https://api.openai.com/v1', 'global')`,
|
||||
configID)
|
||||
}
|
||||
|
||||
return &modelPrefsHarness{
|
||||
testHarness: &testHarness{router: r, t: t},
|
||||
userToken: userToken,
|
||||
userID: userID,
|
||||
user2Token: user2Token,
|
||||
user2ID: user2ID,
|
||||
configID: configID,
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /models/preferences — empty state ─
|
||||
|
||||
func TestModelPrefs_Get_Empty(t *testing.T) {
|
||||
h := setupModelPrefsHarness(t)
|
||||
|
||||
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
|
||||
data, ok := body["data"]
|
||||
if !ok {
|
||||
t.Fatal("response must have 'data' key")
|
||||
}
|
||||
arr, ok := data.([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("data must be an array")
|
||||
}
|
||||
if len(arr) != 0 {
|
||||
t.Fatalf("expected empty array, got %d items", len(arr))
|
||||
}
|
||||
}
|
||||
|
||||
// ── PUT + GET round-trip ──────────────────
|
||||
|
||||
func TestModelPrefs_Set_RoundTrip(t *testing.T) {
|
||||
h := setupModelPrefsHarness(t)
|
||||
|
||||
// Set preference
|
||||
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
|
||||
"model_id": "test-model-1",
|
||||
"provider_config_id": h.configID,
|
||||
"hidden": true,
|
||||
"sort_order": 5,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("PUT: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
// Read back
|
||||
resp = h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("GET: got %d", resp.Code)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
arr := body["data"].([]interface{})
|
||||
if len(arr) != 1 {
|
||||
t.Fatalf("expected 1 preference, got %d", len(arr))
|
||||
}
|
||||
|
||||
pref := arr[0].(map[string]interface{})
|
||||
if pref["model_id"] != "test-model-1" {
|
||||
t.Errorf("model_id: got %v", pref["model_id"])
|
||||
}
|
||||
if pref["provider_config_id"] != h.configID {
|
||||
t.Errorf("provider_config_id: got %v, want %s", pref["provider_config_id"], h.configID)
|
||||
}
|
||||
if pref["hidden"] != true {
|
||||
t.Errorf("hidden: got %v, want true", pref["hidden"])
|
||||
}
|
||||
if pref["sort_order"] != float64(5) {
|
||||
t.Errorf("sort_order: got %v, want 5", pref["sort_order"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upsert — no duplicate rows ────────────
|
||||
|
||||
func TestModelPrefs_Upsert_NoDuplicate(t *testing.T) {
|
||||
h := setupModelPrefsHarness(t)
|
||||
|
||||
for _, hidden := range []bool{true, false, true} {
|
||||
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
|
||||
"model_id": "test-model-1",
|
||||
"provider_config_id": h.configID,
|
||||
"hidden": hidden,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("PUT hidden=%v: got %d", hidden, resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
arr := body["data"].([]interface{})
|
||||
if len(arr) != 1 {
|
||||
t.Fatalf("upsert produced %d rows, want 1", len(arr))
|
||||
}
|
||||
|
||||
pref := arr[0].(map[string]interface{})
|
||||
if pref["hidden"] != true {
|
||||
t.Errorf("final hidden state should be true, got %v", pref["hidden"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation: missing fields ────────────
|
||||
|
||||
func TestModelPrefs_Set_MissingModelID(t *testing.T) {
|
||||
h := setupModelPrefsHarness(t)
|
||||
|
||||
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
|
||||
"provider_config_id": h.configID,
|
||||
"hidden": true,
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing model_id: want 400, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelPrefs_Set_MissingProviderConfigID(t *testing.T) {
|
||||
h := setupModelPrefsHarness(t)
|
||||
|
||||
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
|
||||
"model_id": "test-model-1",
|
||||
"hidden": true,
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing provider_config_id: want 400, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bulk set hidden ───────────────────────
|
||||
|
||||
func TestModelPrefs_BulkSetHidden(t *testing.T) {
|
||||
h := setupModelPrefsHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/models/preferences/bulk", h.userToken, map[string]interface{}{
|
||||
"entries": []map[string]string{
|
||||
{"model_id": "bulk-model-a", "provider_config_id": h.configID},
|
||||
{"model_id": "bulk-model-b", "provider_config_id": h.configID},
|
||||
},
|
||||
"hidden": true,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("bulk: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var bulkResp map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&bulkResp)
|
||||
if bulkResp["count"] != float64(2) {
|
||||
t.Errorf("bulk count: got %v, want 2", bulkResp["count"])
|
||||
}
|
||||
|
||||
// Verify both are hidden
|
||||
resp = h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
arr := body["data"].([]interface{})
|
||||
|
||||
hiddenCount := 0
|
||||
for _, raw := range arr {
|
||||
p := raw.(map[string]interface{})
|
||||
if p["hidden"] == true {
|
||||
hiddenCount++
|
||||
}
|
||||
}
|
||||
if hiddenCount < 2 {
|
||||
t.Errorf("expected at least 2 hidden prefs, got %d", hiddenCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ── User isolation ────────────────────────
|
||||
|
||||
func TestModelPrefs_UserIsolation(t *testing.T) {
|
||||
h := setupModelPrefsHarness(t)
|
||||
|
||||
// User 1 sets a preference
|
||||
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
|
||||
"model_id": "isolated-model",
|
||||
"provider_config_id": h.configID,
|
||||
"hidden": true,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("user1 PUT: got %d", resp.Code)
|
||||
}
|
||||
|
||||
// User 2 should see empty preferences
|
||||
resp = h.request("GET", "/api/v1/models/preferences", h.user2Token, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("user2 GET: got %d", resp.Code)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
arr := body["data"].([]interface{})
|
||||
if len(arr) != 0 {
|
||||
t.Fatalf("user2 should have 0 prefs, got %d", len(arr))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shape validation ──────────────────────
|
||||
|
||||
func TestModelPrefs_ResponseShape(t *testing.T) {
|
||||
h := setupModelPrefsHarness(t)
|
||||
|
||||
// Create a preference so we have something to inspect
|
||||
h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
|
||||
"model_id": "shape-model",
|
||||
"provider_config_id": h.configID,
|
||||
"hidden": false,
|
||||
"sort_order": 0,
|
||||
})
|
||||
|
||||
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
arr := body["data"].([]interface{})
|
||||
if len(arr) == 0 {
|
||||
t.Fatal("expected at least 1 preference for shape check")
|
||||
}
|
||||
|
||||
pref := arr[0].(map[string]interface{})
|
||||
requiredFields := []string{"id", "user_id", "model_id", "provider_config_id", "hidden", "sort_order", "created_at", "updated_at"}
|
||||
for _, f := range requiredFields {
|
||||
if _, ok := pref[f]; !ok {
|
||||
t.Errorf("missing required field %q in preference response", f)
|
||||
}
|
||||
}
|
||||
|
||||
// id, user_id, model_id, provider_config_id should be non-empty strings
|
||||
for _, f := range []string{"id", "user_id", "model_id", "provider_config_id"} {
|
||||
v, _ := pref[f].(string)
|
||||
if v == "" {
|
||||
t.Errorf("field %q should be a non-empty string, got %v", f, pref[f])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Different provider_config_id = different entry ──
|
||||
|
||||
func TestModelPrefs_SameModel_DifferentProvider(t *testing.T) {
|
||||
h := setupModelPrefsHarness(t)
|
||||
|
||||
// Seed a second provider config
|
||||
config2ID := uuid.New().String()
|
||||
database.TestDB.Exec(dialectSQL(
|
||||
"INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope) VALUES ($1, 'global', 'Test Provider 2', 'anthropic', 'https://api.anthropic.com/v1', 'global')"),
|
||||
config2ID)
|
||||
|
||||
// Set preference for same model under two providers
|
||||
h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
|
||||
"model_id": "dual-provider-model",
|
||||
"provider_config_id": h.configID,
|
||||
"hidden": true,
|
||||
})
|
||||
h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
|
||||
"model_id": "dual-provider-model",
|
||||
"provider_config_id": config2ID,
|
||||
"hidden": false,
|
||||
})
|
||||
|
||||
// Should have two distinct entries
|
||||
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
arr := body["data"].([]interface{})
|
||||
|
||||
matches := 0
|
||||
for _, raw := range arr {
|
||||
p := raw.(map[string]interface{})
|
||||
if p["model_id"] == "dual-provider-model" {
|
||||
matches++
|
||||
}
|
||||
}
|
||||
if matches != 2 {
|
||||
t.Fatalf("same model, different providers: expected 2 entries, got %d", matches)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/notifications"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
|
||||
@@ -61,6 +62,16 @@ func setupNotifHarness(t *testing.T) *notifHarness {
|
||||
protected.PUT("/notifications/preferences/:type", notifH.SetPreference)
|
||||
protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference)
|
||||
|
||||
// Admin broadcast route (v0.28.6)
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
admin.Use(middleware.RequireAdmin())
|
||||
admin.POST("/notifications/broadcast", notifH.Broadcast)
|
||||
|
||||
// Set up notification service singleton for Broadcast handler
|
||||
notifSvc := notifications.NewService(stores.Notifications, nil)
|
||||
notifications.SetDefault(notifSvc)
|
||||
|
||||
// Seed admin
|
||||
adminID := database.SeedTestUser(t, "notif-admin", "notif-admin@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), adminID)
|
||||
@@ -548,6 +559,110 @@ func TestNotifications_Unauthenticated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin Broadcast (v0.28.6) ────────────
|
||||
|
||||
func TestBroadcast_Success(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{
|
||||
"title": "Maintenance Tonight",
|
||||
"message": "Servers will be down from 2-4 AM.",
|
||||
"level": "warning",
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("broadcast: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
if body["message"] != "broadcast sent" {
|
||||
t.Errorf("expected 'broadcast sent', got %v", body["message"])
|
||||
}
|
||||
count := body["count"].(float64)
|
||||
if count < 2 {
|
||||
t.Errorf("expected at least 2 recipients (admin+user), got %v", count)
|
||||
}
|
||||
|
||||
// Verify notification created for regular user
|
||||
resp = h.request("GET", "/api/v1/notifications", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("list notifications: got %d", resp.Code)
|
||||
}
|
||||
var listBody map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&listBody)
|
||||
items := listBody["data"].([]interface{})
|
||||
if len(items) == 0 {
|
||||
t.Fatal("user should have received the broadcast notification")
|
||||
}
|
||||
n := items[0].(map[string]interface{})
|
||||
if n["type"] != "system.announcement" {
|
||||
t.Errorf("notification type: got %v, want system.announcement", n["type"])
|
||||
}
|
||||
if n["title"] != "Maintenance Tonight" {
|
||||
t.Errorf("notification title: got %v", n["title"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_NonAdmin403(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.userToken, map[string]interface{}{
|
||||
"title": "Unauthorized",
|
||||
"message": "Should fail.",
|
||||
})
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin broadcast: want 403, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_MissingTitle(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{
|
||||
"message": "No title.",
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing title: want 400, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_MissingMessage(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{
|
||||
"title": "No message.",
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing message: want 400, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_InvalidLevel(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{
|
||||
"title": "Bad level",
|
||||
"message": "Test",
|
||||
"level": "extreme",
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid level: want 400, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_DefaultLevel(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
// Omit level — should default to info and succeed
|
||||
resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{
|
||||
"title": "Info broadcast",
|
||||
"message": "Default level test.",
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("default level: want 200, got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────
|
||||
|
||||
func keys(m map[string]json.RawMessage) []string {
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/notifications"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
@@ -279,3 +281,74 @@ func (h *NotificationHandler) DeletePreference(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// ── Admin Broadcast (v0.28.6) ───────────────
|
||||
|
||||
// POST /api/v1/admin/notifications/broadcast
|
||||
// Admin-only: sends a system.announcement notification to all active users.
|
||||
func (h *NotificationHandler) Broadcast(c *gin.Context) {
|
||||
var req struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Message string `json:"message" binding:"required"`
|
||||
Level string `json:"level"` // info | warning | critical (default: info)
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
level := req.Level
|
||||
if level == "" {
|
||||
level = "info"
|
||||
}
|
||||
if level != "info" && level != "warning" && level != "critical" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "level must be info, warning, or critical"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get all active user IDs
|
||||
userIDs, err := h.stores.Users.ListActiveUserIDs(c.Request.Context())
|
||||
if err != nil {
|
||||
log.Printf("[broadcast] failed to list active users: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(userIDs) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "no active users", "count": 0})
|
||||
return
|
||||
}
|
||||
|
||||
// Fan out via notification service
|
||||
svc := notifications.Default()
|
||||
if svc == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "notification service not available"})
|
||||
return
|
||||
}
|
||||
|
||||
template := models.Notification{
|
||||
Type: models.NotifTypeAnnouncement,
|
||||
Title: req.Title,
|
||||
Body: req.Message,
|
||||
}
|
||||
svc.NotifyMany(c.Request.Context(), userIDs, template)
|
||||
|
||||
// Also emit a real-time WS broadcast for immediate visibility
|
||||
if h.hub != nil {
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"title": req.Title,
|
||||
"message": req.Message,
|
||||
"level": level,
|
||||
})
|
||||
for _, uid := range userIDs {
|
||||
h.hub.SendToUser(uid, events.Event{
|
||||
Label: "system.broadcast",
|
||||
Payload: payload,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[broadcast] admin sent announcement to %d users: %s", len(userIDs), req.Title)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "broadcast sent", "count": len(userIDs)})
|
||||
}
|
||||
|
||||
@@ -141,6 +141,22 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// v0.28.6: System tasks — admin-only, requires valid system_function
|
||||
if t.TaskType == "system" {
|
||||
if c.GetString("role") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "system tasks are admin-only"})
|
||||
return
|
||||
}
|
||||
if t.SystemFunction == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "system_function is required for system tasks"})
|
||||
return
|
||||
}
|
||||
if err := taskutil.ValidateSystemFunc(t.SystemFunction); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if t.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
@@ -540,3 +556,9 @@ func (h *TriggerHandler) Handle(c *gin.Context) {
|
||||
"task_id": task.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// ListSystemFunctions returns the available system function names and descriptions.
|
||||
// GET /admin/system-functions
|
||||
func (h *TaskHandler) ListSystemFunctions(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": taskutil.ListSystemFuncs()})
|
||||
}
|
||||
|
||||
@@ -408,6 +408,9 @@ func main() {
|
||||
|
||||
// v0.27.2: Task scheduler with executor — needs hub + notification service
|
||||
if stores.Tasks != nil {
|
||||
// v0.28.6: Register built-in system task functions
|
||||
scheduler.RegisterBuiltins()
|
||||
|
||||
exec := scheduler.NewExecutor(stores, keyResolver, hub, healthAccum)
|
||||
taskSched := scheduler.New(stores, exec)
|
||||
go taskSched.Run()
|
||||
@@ -813,7 +816,9 @@ func main() {
|
||||
// Git credentials (v0.21.4) — user-scoped, independent of workspace
|
||||
gitCredH := handlers.NewGitCredentialHandler(stores, keyResolver)
|
||||
protected.POST("/git-credentials", gitCredH.Create)
|
||||
protected.POST("/git-credentials/generate", gitCredH.Generate)
|
||||
protected.GET("/git-credentials", gitCredH.List)
|
||||
protected.GET("/git-credentials/:id/public-key", gitCredH.GetPublicKey)
|
||||
protected.DELETE("/git-credentials/:id", gitCredH.Delete)
|
||||
|
||||
// Files (upload/download)
|
||||
@@ -1017,6 +1022,10 @@ func main() {
|
||||
admin.PUT("/teams/:id/members/:memberId", teamAdm.UpdateMember)
|
||||
admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember)
|
||||
|
||||
// Admin broadcast (v0.28.6)
|
||||
adminNotifH := handlers.NewNotificationHandler(stores, hub)
|
||||
admin.POST("/notifications/broadcast", adminNotifH.Broadcast)
|
||||
|
||||
// Audit log
|
||||
admin.GET("/audit", adm.ListAuditLog)
|
||||
admin.GET("/audit/actions", adm.ListAuditActions)
|
||||
@@ -1134,6 +1143,7 @@ func main() {
|
||||
admin.POST("/tasks/:id/run", taskAdm.RunNow)
|
||||
admin.POST("/tasks/:id/kill", taskAdm.KillRun)
|
||||
admin.DELETE("/tasks/:id", taskAdm.Delete)
|
||||
admin.GET("/system-functions", taskAdm.ListSystemFunctions)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,24 +13,33 @@ type GitCredential struct {
|
||||
AuthType string `json:"auth_type" db:"auth_type"` // https_pat, https_basic, ssh_key
|
||||
EncryptedData []byte `json:"-" db:"encrypted_data"` // never expose
|
||||
Nonce []byte `json:"-" db:"nonce"` // never expose
|
||||
PublicKey string `json:"public_key,omitempty" db:"public_key"`
|
||||
Fingerprint string `json:"fingerprint,omitempty" db:"fingerprint"`
|
||||
PersonaID *string `json:"persona_id,omitempty" db:"persona_id"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
}
|
||||
|
||||
// GitCredentialSummary is the safe-to-expose version (no encrypted data).
|
||||
type GitCredentialSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AuthType string `json:"auth_type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AuthType string `json:"auth_type"`
|
||||
PublicKey string `json:"public_key,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
PersonaID *string `json:"persona_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Summary returns a safe version without encrypted fields.
|
||||
func (c *GitCredential) Summary() GitCredentialSummary {
|
||||
return GitCredentialSummary{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
AuthType: c.AuthType,
|
||||
CreatedAt: c.CreatedAt,
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
AuthType: c.AuthType,
|
||||
PublicKey: c.PublicKey,
|
||||
Fingerprint: c.Fingerprint,
|
||||
PersonaID: c.PersonaID,
|
||||
CreatedAt: c.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ const (
|
||||
NotifTypeTaskCompleted = "task.completed"
|
||||
NotifTypeTaskFailed = "task.failed"
|
||||
NotifTypeTaskBudget = "task.budget_exceeded"
|
||||
NotifTypeAnnouncement = "system.announcement"
|
||||
)
|
||||
|
||||
// Resource type constants for click-to-navigate.
|
||||
|
||||
@@ -7,7 +7,8 @@ import (
|
||||
|
||||
// Task is a scheduled, one-shot, or webhook-triggered job that creates a
|
||||
// service channel and runs a completion (prompt task), instantiates a
|
||||
// workflow, or relays a payload without LLM involvement (action task).
|
||||
// workflow, relays a payload without LLM involvement (action task), or
|
||||
// executes a built-in Go function (system task).
|
||||
type Task struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
OwnerID string `json:"owner_id" db:"owner_id"`
|
||||
@@ -17,7 +18,8 @@ type Task struct {
|
||||
Scope string `json:"scope" db:"scope"` // personal | team | global
|
||||
|
||||
// What to run
|
||||
TaskType string `json:"task_type" db:"task_type"` // prompt | workflow | action
|
||||
TaskType string `json:"task_type" db:"task_type"` // prompt | workflow | action | system
|
||||
SystemFunction string `json:"system_function,omitempty" db:"system_function"`
|
||||
PersonaID *string `json:"persona_id,omitempty" db:"persona_id"`
|
||||
ModelID string `json:"model_id" db:"model_id"`
|
||||
SystemPrompt string `json:"system_prompt" db:"system_prompt"`
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/marked.min.js"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/purify.min.js"></script>
|
||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-format.js?v={{.Version}}"></script>
|
||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/virtual-scroll.js?v={{.Version}}"></script>
|
||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-core.js?v={{.Version}}"></script>
|
||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/pages.js?v={{.Version}}"></script>
|
||||
{{/* v0.25.0: Component scripts — available on all surfaces */}}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
Routing
|
||||
</a>
|
||||
<a href="{{$base}}/admin/settings" class="admin-cat-btn{{if eq $section "settings"}} active{{else if eq $section "storage"}} active{{else if eq $section "extensions"}} active{{else if eq $section "channels"}} active{{else if eq $section "surfaces"}} active{{end}}" data-cat="system">
|
||||
<a href="{{$base}}/admin/settings" class="admin-cat-btn{{if eq $section "settings"}} active{{else if eq $section "storage"}} active{{else if eq $section "extensions"}} active{{else if eq $section "channels"}} active{{else if eq $section "surfaces"}} active{{else if eq $section "broadcast"}} active{{end}}" data-cat="system">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09"/></svg>
|
||||
System
|
||||
</a>
|
||||
@@ -220,6 +220,7 @@
|
||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-admin.js?v={{.Version}}"></script>
|
||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/task-admin.js?v={{.Version}}"></script>
|
||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-surfaces.js?v={{.Version}}"></script>
|
||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/broadcast-admin.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}">
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// ── Admin navigation: don't pollute browser history ──
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<a href="{{$base}}/settings/teams" class="settings-nav-link{{if eq $section "teams"}} active{{end}}">Teams</a>
|
||||
<a href="{{$base}}/settings/workflows" class="settings-nav-link{{if eq $section "workflows"}} active{{end}}">Workflows</a>
|
||||
<a href="{{$base}}/settings/tasks" class="settings-nav-link{{if eq $section "tasks"}} active{{end}}">Tasks</a>
|
||||
<a href="{{$base}}/settings/gitkeys" class="settings-nav-link{{if eq $section "gitkeys"}} active{{end}}">Git Keys</a>
|
||||
|
||||
{{/* BYOK-gated nav items */}}
|
||||
<div id="settingsByokNav" style="display:none;">
|
||||
@@ -173,6 +174,7 @@
|
||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-settings.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/settings-handlers.js?v={{.Version}}"></script>
|
||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/task-settings.js?v={{.Version}}"></script>
|
||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/git-credentials-ui.js?v={{.Version}}"></script>
|
||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script>
|
||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/persona-kb.js?v={{.Version}}"></script>
|
||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/memory-ui.js?v={{.Version}}"></script>
|
||||
@@ -256,6 +258,7 @@
|
||||
teams: () => typeof UI !== 'undefined' && UI.loadTeamsSettings?.(),
|
||||
workflows: () => typeof loadTeamWorkflows === 'function' && loadTeamWorkflows(),
|
||||
tasks: () => typeof _loadSettingsTasks === 'function' && _loadSettingsTasks(),
|
||||
gitkeys: () => typeof _loadSettingsGitKeys === 'function' && _loadSettingsGitKeys(),
|
||||
};
|
||||
|
||||
// Populate App.policies and App.models from API.
|
||||
|
||||
@@ -51,6 +51,12 @@ func NewExecutor(stores store.Stores, vault *crypto.KeyResolver, hub *events.Hub
|
||||
func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.TaskRun, channelID string) {
|
||||
startTime := time.Now()
|
||||
|
||||
// v0.28.6: System tasks run a built-in Go function. No LLM, no provider, no channel.
|
||||
if task.TaskType == "system" {
|
||||
e.executeSystem(ctx, task, run, startTime)
|
||||
return
|
||||
}
|
||||
|
||||
// v0.28.0: Action tasks skip the LLM pipeline entirely.
|
||||
if task.TaskType == "action" {
|
||||
e.executeAction(ctx, task, run, channelID, startTime)
|
||||
@@ -259,6 +265,56 @@ func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.Ta
|
||||
}
|
||||
}
|
||||
|
||||
// executeSystem runs a built-in Go function from the system registry.
|
||||
// No LLM, no provider resolution, no channel needed.
|
||||
func (e *Executor) executeSystem(ctx context.Context, task models.Task, run *models.TaskRun, startTime time.Time) {
|
||||
fn, ok := taskutil.GetSystemFunc(task.SystemFunction)
|
||||
if !ok {
|
||||
e.failRun(ctx, task, run, "unknown system function: "+task.SystemFunction)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := fn(ctx, e.stores)
|
||||
|
||||
wallClock := int(time.Since(startTime).Seconds())
|
||||
status := "completed"
|
||||
errMsg := ""
|
||||
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
errMsg = err.Error()
|
||||
}
|
||||
|
||||
// Store result as the run's output (tokens=0, tools=0 for system tasks)
|
||||
_ = e.stores.Tasks.UpdateRun(ctx, run.ID, status, 0, 0, wallClock, errMsg)
|
||||
_ = e.stores.Tasks.IncrementRunCount(ctx, task.ID)
|
||||
|
||||
log.Printf("[executor] System task %s (%s → %s) → %s (wall=%ds, result=%s)",
|
||||
task.ID, task.Name, task.SystemFunction, status, wallClock, truncate(result, 200))
|
||||
|
||||
e.notifyOwner(ctx, task, status, errMsg)
|
||||
|
||||
// Outbound webhook with result
|
||||
if task.WebhookURL != "" {
|
||||
go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{
|
||||
TaskID: task.ID,
|
||||
RunID: run.ID,
|
||||
TaskName: task.Name,
|
||||
Status: status,
|
||||
CompletedAt: time.Now().UTC(),
|
||||
Output: result,
|
||||
Error: errMsg,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
// executeAction handles non-LLM action tasks.
|
||||
// Skips provider resolution and completion entirely.
|
||||
func (e *Executor) executeAction(ctx context.Context, task models.Task, run *models.TaskRun, channelID string, startTime time.Time) {
|
||||
|
||||
130
server/scheduler/system_builtins.go
Normal file
130
server/scheduler/system_builtins.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Package scheduler — system_builtins.go
|
||||
//
|
||||
// v0.28.6: Built-in system functions registered at startup.
|
||||
// These mirror the background goroutines in main.go but run as visible,
|
||||
// scheduled, auditable tasks. The goroutines remain as fallback until
|
||||
// system tasks are validated in production.
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
|
||||
)
|
||||
|
||||
// RegisterBuiltins registers all built-in system functions.
|
||||
// Called once from main.go at startup.
|
||||
func RegisterBuiltins() {
|
||||
taskutil.RegisterSystemFunc("session_cleanup",
|
||||
"Remove expired anonymous visitor sessions",
|
||||
sessionCleanup)
|
||||
|
||||
taskutil.RegisterSystemFunc("staleness_check",
|
||||
"Mark idle workflow instances as stale",
|
||||
stalenessCheck)
|
||||
|
||||
taskutil.RegisterSystemFunc("retention_sweep",
|
||||
"Delete completed workflow instances past retention policy",
|
||||
retentionSweep)
|
||||
|
||||
taskutil.RegisterSystemFunc("health_prune",
|
||||
"Prune provider health windows older than 7 days",
|
||||
healthPrune)
|
||||
}
|
||||
|
||||
// ── session_cleanup ─────────────────────────
|
||||
|
||||
func sessionCleanup(ctx context.Context, stores store.Stores) (string, error) {
|
||||
if stores.Sessions == nil {
|
||||
return "skipped: session store not available", nil
|
||||
}
|
||||
// Default: expire sessions older than 7 days
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -7)
|
||||
n, err := stores.Sessions.DeleteExpired(ctx, cutoff)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("session cleanup failed: %w", err)
|
||||
}
|
||||
msg := fmt.Sprintf("cleaned up %d expired sessions (cutoff: %s)", n, cutoff.Format(time.RFC3339))
|
||||
if n > 0 {
|
||||
log.Printf("[system_task] %s", msg)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// ── staleness_check ─────────────────────────
|
||||
|
||||
func stalenessCheck(ctx context.Context, stores store.Stores) (string, error) {
|
||||
// Default: 48 hours idle → stale
|
||||
cutoff := time.Now().UTC().Add(-48 * time.Hour)
|
||||
res, err := database.DB.ExecContext(ctx, database.Q(`
|
||||
UPDATE channels
|
||||
SET workflow_status = 'stale'
|
||||
WHERE type = 'workflow'
|
||||
AND workflow_status = 'active'
|
||||
AND last_activity_at < $1
|
||||
`), cutoff)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("staleness sweep failed: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
msg := fmt.Sprintf("marked %d idle workflow instances as stale (cutoff: %s)", n, cutoff.Format(time.RFC3339))
|
||||
if n > 0 {
|
||||
log.Printf("[system_task] %s", msg)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// ── retention_sweep ─────────────────────────
|
||||
|
||||
func retentionSweep(ctx context.Context, stores store.Stores) (string, error) {
|
||||
if database.IsSQLite() {
|
||||
return "skipped: retention enforcement requires PostgreSQL (JSON operators)", nil
|
||||
}
|
||||
cutoff := time.Now().UTC().Add(-48 * time.Hour)
|
||||
res, err := database.DB.ExecContext(ctx, database.Q(`
|
||||
DELETE FROM channels
|
||||
WHERE type = 'workflow'
|
||||
AND workflow_status IN ('completed', 'archived')
|
||||
AND workflow_id IS NOT NULL
|
||||
AND last_activity_at < $1
|
||||
AND workflow_id IN (
|
||||
SELECT id FROM workflows
|
||||
WHERE retention IS NOT NULL
|
||||
AND retention->>'mode' = 'delete'
|
||||
AND (retention->>'delete_after_days')::int > 0
|
||||
AND channels.last_activity_at < now() - ((retention->>'delete_after_days')::int || ' days')::interval
|
||||
)
|
||||
`), cutoff)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("retention enforcement failed: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
msg := fmt.Sprintf("deleted %d expired workflow instances (retention policy)", n)
|
||||
if n > 0 {
|
||||
log.Printf("[system_task] %s", msg)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// ── health_prune ────────────────────────────
|
||||
|
||||
func healthPrune(ctx context.Context, stores store.Stores) (string, error) {
|
||||
cutoff := time.Now().UTC().Add(-7 * 24 * time.Hour)
|
||||
res, err := database.DB.ExecContext(ctx, database.Q(`
|
||||
DELETE FROM health_windows WHERE window_start < $1
|
||||
`), cutoff)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("health prune failed: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
msg := fmt.Sprintf("pruned %d old health windows (cutoff: %s)", n, cutoff.Format(time.RFC3339))
|
||||
if n > 0 {
|
||||
log.Printf("[system_task] %s", msg)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
@@ -183,6 +183,7 @@ type UserStore interface {
|
||||
List(ctx context.Context, opts ListOptions) ([]models.User, int, error)
|
||||
UpdateLastLogin(ctx context.Context, id string) error
|
||||
SetActive(ctx context.Context, id string, active bool) error
|
||||
ListActiveUserIDs(ctx context.Context) ([]string, error)
|
||||
|
||||
// Refresh tokens
|
||||
CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error
|
||||
|
||||
@@ -13,29 +13,32 @@ type GitCredentialStore struct{}
|
||||
func (s *GitCredentialStore) Create(ctx context.Context, cred *models.GitCredential) error {
|
||||
if cred.ID != "" {
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING created_at`,
|
||||
cred.ID, cred.UserID, cred.Name, cred.AuthType,
|
||||
cred.EncryptedData, cred.Nonce,
|
||||
cred.PublicKey, cred.Fingerprint, cred.PersonaID,
|
||||
).Scan(&cred.CreatedAt)
|
||||
}
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO git_credentials (user_id, name, auth_type, encrypted_data, nonce)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO git_credentials (user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, created_at`,
|
||||
cred.UserID, cred.Name, cred.AuthType,
|
||||
cred.EncryptedData, cred.Nonce,
|
||||
cred.PublicKey, cred.Fingerprint, cred.PersonaID,
|
||||
).Scan(&cred.ID, &cred.CreatedAt)
|
||||
}
|
||||
|
||||
func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.GitCredential, error) {
|
||||
var c models.GitCredential
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at
|
||||
SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at
|
||||
FROM git_credentials WHERE id = $1`, id,
|
||||
).Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
|
||||
&c.EncryptedData, &c.Nonce, &c.CreatedAt)
|
||||
&c.EncryptedData, &c.Nonce,
|
||||
&c.PublicKey, &c.Fingerprint, &c.PersonaID, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -44,7 +47,7 @@ func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.Gi
|
||||
|
||||
func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at
|
||||
SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at
|
||||
FROM git_credentials WHERE user_id = $1 ORDER BY created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -55,7 +58,8 @@ func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]m
|
||||
for rows.Next() {
|
||||
var c models.GitCredential
|
||||
if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
|
||||
&c.EncryptedData, &c.Nonce, &c.CreatedAt); err != nil {
|
||||
&c.EncryptedData, &c.Nonce,
|
||||
&c.PublicKey, &c.Fingerprint, &c.PersonaID, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
creds = append(creds, c)
|
||||
|
||||
@@ -23,7 +23,8 @@ func NewTaskStore() *TaskStore { return &TaskStore{} }
|
||||
// (string, json.RawMessage). database/sql returns "unsupported Scan,
|
||||
// storing driver.Value type <nil>" for these without COALESCE.
|
||||
const taskColumns = `id, owner_id, team_id, name, description, scope,
|
||||
task_type, persona_id, model_id, system_prompt, user_prompt,
|
||||
task_type, COALESCE(system_function, '') AS system_function,
|
||||
persona_id, model_id, system_prompt, user_prompt,
|
||||
workflow_id, COALESCE(tool_grants, '[]'::jsonb) AS tool_grants,
|
||||
schedule, timezone, is_active,
|
||||
COALESCE(trigger_token, '') AS trigger_token,
|
||||
@@ -40,7 +41,8 @@ const taskColumns = `id, owner_id, team_id, name, description, scope,
|
||||
func scanTask(scanner interface{ Scan(...interface{}) error }, t *models.Task) error {
|
||||
return scanner.Scan(
|
||||
&t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope,
|
||||
&t.TaskType, &t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt,
|
||||
&t.TaskType, &t.SystemFunction,
|
||||
&t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt,
|
||||
&t.WorkflowID, &t.ToolGrants, &t.Schedule, &t.Timezone, &t.IsActive,
|
||||
&t.TriggerToken,
|
||||
&t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode,
|
||||
@@ -56,16 +58,18 @@ func (s *TaskStore) Create(ctx context.Context, t *models.Task) error {
|
||||
toolGrants := jsonOrNull(t.ToolGrants)
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO tasks (owner_id, team_id, name, description, scope,
|
||||
task_type, persona_id, model_id, system_prompt, user_prompt,
|
||||
task_type, system_function,
|
||||
persona_id, model_id, system_prompt, user_prompt,
|
||||
workflow_id, tool_grants, schedule, timezone, is_active,
|
||||
trigger_token,
|
||||
max_tokens, max_tool_calls, max_wall_clock, output_mode,
|
||||
output_channel_id, webhook_url, webhook_secret, provider_config_id,
|
||||
notify_on_complete, notify_on_failure, next_run_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28)
|
||||
RETURNING id, created_at, updated_at`,
|
||||
t.OwnerID, t.TeamID, t.Name, t.Description, t.Scope,
|
||||
t.TaskType, t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt,
|
||||
t.TaskType, t.SystemFunction,
|
||||
t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt,
|
||||
t.WorkflowID, toolGrants, t.Schedule, t.Timezone, t.IsActive,
|
||||
nilIfEmpty(t.TriggerToken),
|
||||
t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode,
|
||||
|
||||
@@ -122,6 +122,23 @@ func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
|
||||
rows, err := DB.QueryContext(ctx, "SELECT id FROM users WHERE is_active = true")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// ── Refresh Tokens ──────────────────────────
|
||||
|
||||
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
|
||||
|
||||
@@ -18,11 +18,12 @@ func (s *GitCredentialStore) Create(ctx context.Context, cred *models.GitCredent
|
||||
cred.ID = uuid.NewString()
|
||||
}
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||
INSERT INTO git_credentials (id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||
cred.ID, cred.UserID, cred.Name, cred.AuthType,
|
||||
base64.StdEncoding.EncodeToString(cred.EncryptedData),
|
||||
base64.StdEncoding.EncodeToString(cred.Nonce),
|
||||
cred.PublicKey, cred.Fingerprint, cred.PersonaID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -37,10 +38,10 @@ func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.Gi
|
||||
var c models.GitCredential
|
||||
var encData, nonce string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at
|
||||
SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at
|
||||
FROM git_credentials WHERE id = ?`, id,
|
||||
).Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
|
||||
&encData, &nonce, st(&c.CreatedAt))
|
||||
&encData, &nonce, &c.PublicKey, &c.Fingerprint, &c.PersonaID, st(&c.CreatedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -51,7 +52,7 @@ func (s *GitCredentialStore) GetByID(ctx context.Context, id string) (*models.Gi
|
||||
|
||||
func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]models.GitCredential, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, user_id, name, auth_type, encrypted_data, nonce, created_at
|
||||
SELECT id, user_id, name, auth_type, encrypted_data, nonce, public_key, fingerprint, persona_id, created_at
|
||||
FROM git_credentials WHERE user_id = ? ORDER BY created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -63,7 +64,7 @@ func (s *GitCredentialStore) ListByUser(ctx context.Context, userID string) ([]m
|
||||
var c models.GitCredential
|
||||
var encData, nonce string
|
||||
if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.AuthType,
|
||||
&encData, &nonce, st(&c.CreatedAt)); err != nil {
|
||||
&encData, &nonce, &c.PublicKey, &c.Fingerprint, &c.PersonaID, st(&c.CreatedAt)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.EncryptedData, _ = base64.StdEncoding.DecodeString(encData)
|
||||
|
||||
@@ -19,7 +19,8 @@ func NewTaskStore() *TaskStore { return &TaskStore{} }
|
||||
// COALESCE wraps nullable columns that scan into non-pointer Go types.
|
||||
// tool_grants uses COALESCE to '[]' so the string intermediate never sees NULL.
|
||||
const taskColumns = `id, owner_id, team_id, name, description, scope,
|
||||
task_type, persona_id, model_id, system_prompt, user_prompt,
|
||||
task_type, COALESCE(system_function, '') AS system_function,
|
||||
persona_id, model_id, system_prompt, user_prompt,
|
||||
workflow_id, COALESCE(tool_grants, '[]') AS tool_grants,
|
||||
schedule, timezone, is_active,
|
||||
COALESCE(trigger_token, '') AS trigger_token,
|
||||
@@ -47,7 +48,8 @@ func scanTask(scanner interface{ Scan(...interface{}) error }, t *models.Task) e
|
||||
var isActive, notifyComplete, notifyFailure int
|
||||
err := scanner.Scan(
|
||||
&t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope,
|
||||
&t.TaskType, &t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt,
|
||||
&t.TaskType, &t.SystemFunction,
|
||||
&t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt,
|
||||
&t.WorkflowID, &toolGrantsStr, &t.Schedule, &t.Timezone, &isActive,
|
||||
&t.TriggerToken,
|
||||
&t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode,
|
||||
@@ -74,15 +76,17 @@ func (s *TaskStore) Create(ctx context.Context, t *models.Task) error {
|
||||
t.UpdatedAt = now
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO tasks (id, owner_id, team_id, name, description, scope,
|
||||
task_type, persona_id, model_id, system_prompt, user_prompt,
|
||||
task_type, system_function,
|
||||
persona_id, model_id, system_prompt, user_prompt,
|
||||
workflow_id, tool_grants, schedule, timezone, is_active,
|
||||
trigger_token,
|
||||
max_tokens, max_tool_calls, max_wall_clock, output_mode,
|
||||
output_channel_id, webhook_url, webhook_secret, provider_config_id,
|
||||
notify_on_complete, notify_on_failure, next_run_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
t.ID, t.OwnerID, t.TeamID, t.Name, t.Description, t.Scope,
|
||||
t.TaskType, t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt,
|
||||
t.TaskType, t.SystemFunction,
|
||||
t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt,
|
||||
t.WorkflowID, nullableJSON(t.ToolGrants), t.Schedule, t.Timezone, boolToInt(t.IsActive),
|
||||
nilIfEmpty(t.TriggerToken),
|
||||
t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode,
|
||||
|
||||
@@ -128,6 +128,23 @@ func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
|
||||
rows, err := DB.QueryContext(ctx, "SELECT id FROM users WHERE is_active = 1")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// ── Refresh Tokens ──────────────────────────
|
||||
|
||||
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
|
||||
|
||||
89
server/taskutil/system_registry.go
Normal file
89
server/taskutil/system_registry.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// Package taskutil — system_registry.go
|
||||
//
|
||||
// v0.28.6: System task function registry.
|
||||
//
|
||||
// Built-in Go functions that run as auditable, scheduled tasks instead of
|
||||
// ad-hoc goroutines. Each function receives a context, the full store set,
|
||||
// and returns a structured result. The registry is permanent — system
|
||||
// functions are not replaced by Starlark (v0.29.0). Core platform ops
|
||||
// must not break from bad user code.
|
||||
//
|
||||
// Registration happens at init time from main.go. The executor calls
|
||||
// Get() to look up a function by name at execution time.
|
||||
package taskutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// SystemFunc is the signature for built-in system task functions.
|
||||
// The function receives a context and the full store set. It returns
|
||||
// a human-readable result string (logged in the task run) and an error.
|
||||
type SystemFunc func(ctx context.Context, stores store.Stores) (string, error)
|
||||
|
||||
// SystemFuncInfo describes a registered system function.
|
||||
type SystemFuncInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
var (
|
||||
registryMu sync.RWMutex
|
||||
registry = make(map[string]registryEntry)
|
||||
)
|
||||
|
||||
type registryEntry struct {
|
||||
fn SystemFunc
|
||||
description string
|
||||
}
|
||||
|
||||
// RegisterSystemFunc registers a named system function.
|
||||
// Call during init (before scheduler starts). Not goroutine-safe for writes.
|
||||
func RegisterSystemFunc(name, description string, fn SystemFunc) {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
registry[name] = registryEntry{fn: fn, description: description}
|
||||
}
|
||||
|
||||
// GetSystemFunc returns a system function by name.
|
||||
func GetSystemFunc(name string) (SystemFunc, bool) {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
e, ok := registry[name]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return e.fn, true
|
||||
}
|
||||
|
||||
// ListSystemFuncs returns all registered system function names and descriptions.
|
||||
func ListSystemFuncs() []SystemFuncInfo {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
result := make([]SystemFuncInfo, 0, len(registry))
|
||||
for name, e := range registry {
|
||||
result = append(result, SystemFuncInfo{Name: name, Description: e.description})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
||||
return result
|
||||
}
|
||||
|
||||
// ValidateSystemFunc returns an error if the function name is not registered.
|
||||
func ValidateSystemFunc(name string) error {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
if _, ok := registry[name]; !ok {
|
||||
names := make([]string, 0, len(registry))
|
||||
for n := range registry {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return fmt.Errorf("unknown system function %q (available: %v)", name, names)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user