Changeset 0.27.4 (#171)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ───────────
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user