Changeset 0.27.4 (#171)

This commit is contained in:
2026-03-11 10:30:12 +00:00
parent a46ec63464
commit 07432233f7
13 changed files with 736 additions and 57 deletions

View File

@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
@@ -50,6 +51,33 @@ func (h *TaskHandler) ListAll(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": tasks})
}
// ListTeamTasks returns tasks scoped to the given team.
// Any team member can view; RequireTeamMember middleware enforces access.
// GET /api/v1/teams/:teamId/tasks
func (h *TaskHandler) ListTeamTasks(c *gin.Context) {
teamID := c.Param("teamId")
tasks, err := h.stores.Tasks.ListByTeam(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team tasks"})
return
}
if tasks == nil {
tasks = []models.Task{}
}
c.JSON(http.StatusOK, gin.H{"data": tasks})
}
// CreateTeamTask creates a task scoped to the team.
// Team admins only (RequireTeamAdmin middleware).
// POST /api/v1/teams/:teamId/tasks
func (h *TaskHandler) CreateTeamTask(c *gin.Context) {
teamID := c.Param("teamId")
// Inject team context, then delegate to Create
c.Set("force_team_id", teamID)
c.Set("force_scope", "team")
h.Create(c)
}
// Create creates a new task.
// POST /api/v1/tasks
func (h *TaskHandler) Create(c *gin.Context) {
@@ -70,6 +98,16 @@ func (h *TaskHandler) Create(c *gin.Context) {
t.OwnerID = c.GetString("user_id")
// v0.27.5: Team task context override (set by CreateTeamTask)
if forceTeam, ok := c.Get("force_team_id"); ok {
teamID := forceTeam.(string)
t.TeamID = &teamID
t.Scope = "team"
}
if forceScope, ok := c.Get("force_scope"); ok {
t.Scope = forceScope.(string)
}
// v0.27.2: Personal task check — non-admin users need tasks.allow_personal
if t.Scope == "personal" || t.Scope == "" {
if c.GetString("role") != "admin" && !taskCfg.AllowPersonal {
@@ -143,6 +181,45 @@ func (h *TaskHandler) Create(c *gin.Context) {
c.JSON(http.StatusCreated, t)
}
// canAccessTask returns true if the user can view this task.
// Access: owner, system admin, or team member (for team-scoped tasks).
func (h *TaskHandler) canAccessTask(c *gin.Context, t *models.Task) bool {
if c.GetString("role") == "admin" {
return true
}
if t.OwnerID == c.GetString("user_id") {
return true
}
// Team members can view team-scoped tasks
if t.Scope == "team" && t.TeamID != nil {
var exists bool
database.DB.QueryRow(database.Q(
`SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)`,
), *t.TeamID, c.GetString("user_id")).Scan(&exists)
return exists
}
return false
}
// canMutateTask returns true if the user can edit/delete/run this task.
// Mutation: owner, system admin, or team admin (for team-scoped tasks).
func (h *TaskHandler) canMutateTask(c *gin.Context, t *models.Task) bool {
if c.GetString("role") == "admin" {
return true
}
if t.OwnerID == c.GetString("user_id") {
return true
}
if t.Scope == "team" && t.TeamID != nil {
var teamRole string
database.DB.QueryRow(database.Q(
`SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2`,
), *t.TeamID, c.GetString("user_id")).Scan(&teamRole)
return teamRole == "admin"
}
return false
}
// Get returns a single task.
// GET /api/v1/tasks/:id
func (h *TaskHandler) Get(c *gin.Context) {
@@ -152,8 +229,7 @@ func (h *TaskHandler) Get(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
// Ownership check (admin bypasses)
if c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
if !h.canAccessTask(c, t) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
@@ -172,7 +248,7 @@ func (h *TaskHandler) Update(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
if !h.canMutateTask(c, t) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
@@ -220,7 +296,7 @@ func (h *TaskHandler) Delete(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
if !h.canMutateTask(c, t) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
@@ -236,7 +312,20 @@ func (h *TaskHandler) Delete(c *gin.Context) {
// GET /api/v1/tasks/:id/runs
func (h *TaskHandler) ListRuns(c *gin.Context) {
id := c.Param("id")
runs, err := h.stores.Tasks.ListRuns(c.Request.Context(), id, 50)
ctx := c.Request.Context()
// Access check (v0.27.5)
t, err := h.stores.Tasks.GetByID(ctx, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if !h.canAccessTask(c, t) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
runs, err := h.stores.Tasks.ListRuns(ctx, id, 50)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list runs"})
return
@@ -265,7 +354,7 @@ func (h *TaskHandler) RunNow(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
if !h.canMutateTask(c, t) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
@@ -297,7 +386,7 @@ func (h *TaskHandler) KillRun(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
if !h.canMutateTask(c, t) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}

View File

@@ -909,6 +909,23 @@ func main() {
// Team workflow assignments (v0.26.4)
teamAssignH := handlers.NewWorkflowAssignmentHandler()
teamScoped.GET("/assignments", teamAssignH.ListForTeam)
// Team tasks — admin CRUD (v0.27.5)
teamTaskH := handlers.NewTaskHandler(stores)
teamScoped.POST("/tasks", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.CreateTeamTask)
teamScoped.PUT("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.Update)
teamScoped.DELETE("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.Delete)
teamScoped.POST("/tasks/:id/run", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.RunNow)
teamScoped.POST("/tasks/:id/kill", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.KillRun)
}
// Team task viewing for all members (v0.27.5)
teamMemberRoutes := protected.Group("/teams/:teamId")
teamMemberRoutes.Use(middleware.RequireTeamMember())
{
teamMemberTaskH := handlers.NewTaskHandler(stores)
teamMemberRoutes.GET("/tasks", teamMemberTaskH.ListTeamTasks)
teamMemberRoutes.GET("/tasks/:id/runs", teamMemberTaskH.ListRuns)
}
// Public global settings (non-admin users can read safe subset)

View File

@@ -532,5 +532,6 @@ window.addEventListener('unhandledrejection', function(e) {
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-api.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-admin.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-queue.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/task-sidebar.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/app.js?v={{$.Version}}"></script>
{{end}}

View File

@@ -31,6 +31,7 @@
<a href="{{$base}}/settings/profile" class="settings-nav-link{{if eq $section "profile"}} active{{end}}">Profile</a>
<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>
{{/* BYOK-gated nav items */}}
<div id="settingsByokNav" style="display:none;">
@@ -56,7 +57,7 @@
{{/* Content */}}
<div class="settings-content" id="settingsContentMount">
<div id="settingsSection" data-section="{{.Section}}">
<h2 id="settingsSectionTitle">{{if eq .Section "general"}}General{{else if eq .Section "appearance"}}Appearance{{else if eq .Section "models"}}Models{{else if eq .Section "personas"}}Personas{{else if eq .Section "profile"}}Profile{{else if eq .Section "teams"}}Teams{{else if eq .Section "workflows"}}Workflows{{else if eq .Section "providers"}}My Providers{{else if eq .Section "roles"}}Model Roles{{else if eq .Section "usage"}}My Usage{{else if eq .Section "knowledge"}}Knowledge Bases{{else if eq .Section "memory"}}Memory{{else if eq .Section "notifications"}}Notifications{{else}}Settings{{end}}</h2>
<h2 id="settingsSectionTitle">{{if eq .Section "general"}}General{{else if eq .Section "appearance"}}Appearance{{else if eq .Section "models"}}Models{{else if eq .Section "personas"}}Personas{{else if eq .Section "profile"}}Profile{{else if eq .Section "teams"}}Teams{{else if eq .Section "workflows"}}Workflows{{else if eq .Section "tasks"}}Tasks{{else if eq .Section "providers"}}My Providers{{else if eq .Section "roles"}}Model Roles{{else if eq .Section "usage"}}My Usage{{else if eq .Section "knowledge"}}Knowledge Bases{{else if eq .Section "memory"}}Memory{{else if eq .Section "notifications"}}Notifications{{else}}Settings{{end}}</h2>
{{if eq .Section "general"}}
<div class="settings-section">
@@ -154,6 +155,8 @@
<div id="settingsDynamic"><div class="settings-placeholder">Loading teams…</div></div>
{{else if eq .Section "workflows"}}
<div id="settingsDynamic"><div class="settings-placeholder">Loading workflows…</div></div>
{{else if eq .Section "tasks"}}
<div id="settingsTasksMount"><div class="settings-placeholder">Loading tasks…</div></div>
{{else}}<div class="settings-placeholder" id="settingsDynamic">Loading…</div>
{{end}}
</div>
@@ -169,6 +172,7 @@
{{define "scripts-settings"}}
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-settings.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/settings-handlers.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/task-settings.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/persona-kb.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/memory-ui.js?v={{.Version}}"></script>
@@ -251,6 +255,7 @@
notifications: () => typeof NotifPrefs !== 'undefined' && NotifPrefs.load?.(),
teams: () => typeof UI !== 'undefined' && UI.loadTeamsSettings?.(),
workflows: () => typeof loadTeamWorkflows === 'function' && loadTeamWorkflows(),
tasks: () => typeof _loadSettingsTasks === 'function' && _loadSettingsTasks(),
};
// Populate App.policies and App.models from API.

View File

@@ -21,6 +21,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
"git.gobha.me/xcaliber/chat-switchboard/tools"
"git.gobha.me/xcaliber/chat-switchboard/webhook"
)
@@ -59,6 +60,15 @@ func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.Ta
return
}
// v0.27.4: Enforce personal_require_byok — personal tasks must use BYOK provider
if task.Scope == "personal" && res.ProviderScope != "personal" {
cfg := taskutil.LoadTaskConfig(ctx, e.stores.GlobalConfig)
if cfg.PersonalRequireBYOK {
e.failRun(ctx, task, run, "personal tasks require a BYOK provider — add an API key in Settings → Providers")
return
}
}
provider, err := providers.Get(res.ProviderID)
if err != nil {
e.failRun(ctx, task, run, "provider unavailable: "+err.Error())
@@ -162,16 +172,36 @@ func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.Ta
Streaming: false, // headless — use ChatCompletion
}, sink)
// ── 6. Persist assistant response ──────────
// ── 6. Persist output based on output_mode ──
wallClock := int(time.Since(startTime).Seconds())
if result.Content != "" && e.stores.Messages != nil {
_ = e.stores.Messages.Create(ctx, &models.Message{
ChannelID: channelID,
Role: "assistant",
Content: result.Content,
Model: res.Model,
})
if result.Content != "" {
switch task.OutputMode {
case "note":
// v0.27.4: Save output as a note
if e.stores.Notes != nil {
noteTitle := task.Name + " — " + time.Now().Format("2006-01-02 15:04")
_ = e.stores.Notes.Create(ctx, &models.Note{
UserID: task.OwnerID,
Title: noteTitle,
Content: result.Content,
SourceChannelID: &channelID,
TeamID: task.TeamID,
Tags: []string{"task-output"},
})
}
case "webhook":
// Webhook delivery handled in step 10 below
default: // "channel"
if e.stores.Messages != nil {
_ = e.stores.Messages.Create(ctx, &models.Message{
ChannelID: channelID,
Role: "assistant",
Content: result.Content,
Model: res.Model,
})
}
}
}
// ── 7. Determine terminal status ───────────

View File

@@ -11,25 +11,27 @@ import (
// TaskConfig holds the runtime task configuration read from global_settings.
// Keys: tasks.enabled, tasks.allow_personal, tasks.max_concurrent,
// tasks.default_max_tokens, tasks.default_max_tool_calls,
// tasks.default_max_wall_clock
// tasks.default_max_wall_clock, tasks.personal_require_byok
type TaskConfig struct {
Enabled bool
AllowPersonal bool
MaxConcurrent int
DefaultMaxTokens int
DefaultMaxToolCalls int
DefaultMaxWallClock int // seconds
Enabled bool
AllowPersonal bool
PersonalRequireBYOK bool
MaxConcurrent int
DefaultMaxTokens int
DefaultMaxToolCalls int
DefaultMaxWallClock int // seconds
}
// DefaultTaskConfig returns sensible defaults when no global config is set.
func DefaultTaskConfig() TaskConfig {
return TaskConfig{
Enabled: true,
AllowPersonal: true,
MaxConcurrent: 5,
DefaultMaxTokens: 4096,
DefaultMaxToolCalls: 10,
DefaultMaxWallClock: 300,
Enabled: true,
AllowPersonal: true,
PersonalRequireBYOK: false,
MaxConcurrent: 5,
DefaultMaxTokens: 4096,
DefaultMaxToolCalls: 10,
DefaultMaxWallClock: 300,
}
}
@@ -52,6 +54,9 @@ func LoadTaskConfig(ctx context.Context, gc store.GlobalConfigStore) TaskConfig
if v, ok := boolVal(raw, "allow_personal"); ok {
cfg.AllowPersonal = v
}
if v, ok := boolVal(raw, "personal_require_byok"); ok {
cfg.PersonalRequireBYOK = v
}
if v, ok := intVal(raw, "max_concurrent"); ok && v > 0 {
cfg.MaxConcurrent = v
}