From 07432233f799345c01c62f7a02c8ffce154300bb Mon Sep 17 00:00:00 2001 From: xcaliber Date: Wed, 11 Mar 2026 10:30:12 +0000 Subject: [PATCH] Changeset 0.27.4 (#171) --- CHANGELOG.md | 83 ++++++ VERSION | 2 +- docs/ROADMAP.md | 42 ++- server/handlers/tasks.go | 103 ++++++- server/main.go | 17 ++ server/pages/templates/surfaces/chat.html | 1 + server/pages/templates/surfaces/settings.html | 7 +- server/scheduler/executor.go | 46 ++- server/taskutil/task_config.go | 31 +- src/js/task-admin.js | 34 ++- src/js/task-settings.js | 281 ++++++++++++++++++ src/js/task-sidebar.js | 143 +++++++++ src/js/ui-core.js | 3 + 13 files changed, 736 insertions(+), 57 deletions(-) create mode 100644 src/js/task-settings.js create mode 100644 src/js/task-sidebar.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 635c025..2f65411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,88 @@ # Changelog +## [0.27.5] — 2026-03-11 + +### Summary + +Team tasks — team members can view team-scoped tasks, team admins can +create and manage them. Settings and sidebar surfaces show team tasks +alongside personal tasks with team attribution badges. + +### Added + +#### Team Task Routes +- `GET /api/v1/teams/:teamId/tasks` — list team tasks (any team member). +- `POST /api/v1/teams/:teamId/tasks` — create team-scoped task (team admin). +- `PUT/DELETE/run/kill` on team-scoped task routes (team admin). + +#### Access Control Helpers +- `canAccessTask()` — owner, system admin, or team member for team tasks. +- `canMutateTask()` — owner, system admin, or team admin for team tasks. +- Both used across Get, Update, Delete, ListRuns, RunNow, KillRun. + +#### Frontend +- Settings Tasks section fetches team tasks via `/teams/mine` + per-team + `/teams/:id/tasks`. Shows team badge on team-scoped tasks. +- Sidebar Tasks section includes active team tasks with `[TeamName]` label. + +### Fixed +- `ListRuns` handler now checks task access (was unauthenticated — any + user with a task ID could read run history). + + + +## [0.27.4] — 2026-03-11 + +### Summary + +Personal tasks — the user-facing task experience. Settings surface gains a +Tasks section with CRUD, schedule builder (presets + custom cron + timezone), +and 5 starter templates. Chat sidebar gains a Tasks section showing active +tasks with status indicators and run-now buttons. Executor gains note output +mode and BYOK provider enforcement. + +### Added + +#### Settings → Tasks Section (`task-settings.js`) +- **Task list** with name, schedule, timezone, last/next run, status badges. + Pause/resume toggle, run-now, and delete buttons per task. +- **Create task form:** name, model, prompt editor, schedule builder (6 + presets + custom cron), timezone (defaults to browser), output mode + (channel/note/webhook), budget overrides, notification toggles. +- **5 starter templates:** Morning News Digest, Daily Standup Prep, Weekly + Project Summary, Stock Watchlist Check, Research Digest. Click to + pre-populate the create form. + +#### Tasks Sidebar Section (`task-sidebar.js`) +- New collapsible sidebar section (after Workflows/Queue). +- Shows active tasks with status indicators: ✓ (last run succeeded), + ◷ (scheduled). Click opens the task's output channel. +- Per-task ▶ run-now button. +- Auto-refreshes on chat list update. + +#### BYOK Provider Enforcement +- `tasks.personal_require_byok` global config key (default: false). When + true, personal-scope tasks fail with a clear error if no BYOK provider + is available — prevents fallthrough to org providers. +- Admin configuration UI in admin Tasks → Configuration tab. + +#### Note Output Mode +- `output_mode: "note"` now functional. Task executor creates a note + (title: "{task name} — {timestamp}", tags: ["task-output"]) instead of + a message in the service channel. Good for structured reports. + +### Changed +- `taskutil.TaskConfig` gains `PersonalRequireBYOK` field. +- `executor.go` persistence section rewritten as `output_mode` switch + (channel/note/webhook). +- `task-admin.js` config tab gains "Require BYOK for Personal" toggle. +- `ui-core.js` `renderChatList` refreshes `TaskSidebar` alongside + `WorkflowQueue`. +- `settings.html` gains Tasks nav link and panel mount point. +- `chat.html` includes `task-sidebar.js`. + + + ## [0.27.3] — 2026-03-10 ### Summary diff --git a/VERSION b/VERSION index d0ca6fd..6ca8061 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.27.3 \ No newline at end of file +0.27.5 \ No newline at end of file diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cea5c2b..2352108 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -170,10 +170,14 @@ v0.27.3 Task Chaining ✅ (webhooks, task_create tool, workflow-to-workflow) │ -v0.27.4 Personal Tasks +v0.27.4 Personal Tasks ✅ (BYOK scheduling, user task UI, starter templates) │ +v0.27.5 Team Tasks ✅ + (team task routes, member visibility, + team badges in settings + sidebar) + │ v0.28.0 Platform Polish (virtual scroll, KB auto-inject, Helm chart, provider model prefs) @@ -987,20 +991,20 @@ Depends on: v0.27.2 (task budgets). --- -## v0.27.4 — Personal Tasks: BYOK Scheduling + User Task UI +## v0.27.4 — Personal Tasks: BYOK Scheduling + User Task UI ✅ User-facing task experience. Personal tasks run against BYOK providers. Depends on: v0.27.2 (admin controls, `tasks.allow_personal`). -- [ ] Settings → Tasks section (CRUD, schedule builder, budget config) -- [ ] Schedule builder: preset crons + custom 5-field cron + timezone selector -- [ ] BYOK provider routing for personal tasks -- [ ] `tasks.personal_require_byok` config key (prevent fallthrough to org providers) -- [ ] Output modes: channel (default), note, webhook -- [ ] Task notification booleans: `notify_on_complete` (default false), `notify_on_failure` (default true) -- [ ] Tasks sidebar section with status indicators (✓/✗/⏳/▶) -- [ ] "Run Now" button for manual trigger -- [ ] 3–5 optional starter templates (news digest, stock screener, standup prep, etc.) +- [x] Settings → Tasks section (CRUD, schedule builder, budget config) +- [x] Schedule builder: preset crons + custom 5-field cron + timezone selector +- [x] BYOK provider routing for personal tasks +- [x] `tasks.personal_require_byok` config key (prevent fallthrough to org providers) +- [x] Output modes: channel (default), note, webhook +- [x] Task notification booleans: `notify_on_complete` (default false), `notify_on_failure` (default true) +- [x] Tasks sidebar section with status indicators (✓/⏳/▶) +- [x] "Run Now" button for manual trigger +- [x] 5 starter templates (news digest, standup prep, weekly summary, stock check, research digest) --- @@ -1091,4 +1095,18 @@ based on need. ~~**Surfaces as Extensions**~~ → core infrastructure shipped in v0.25.0. Marketplace, Surface IDE, and project-bound defaults remain future items: - Surface IDE: built-in surface for building surfaces — Go template editor for server-rendered shells, JS/CSS editor for client behavior, live preview in sandboxed region - Surface marketplace: share custom surfaces across instances (presentation mode, kanban, form builder, dashboard, etc.) -- Project-bound surface/pane defaults: project config specifies which panes are available and default layout \ No newline at end of file +- Project-bound surface/pane defaults: project config specifies which panes are available and default layout +--- + +## v0.27.5 — Team Tasks ✅ + +Team-visible task management. Members can view, admins can CRUD. +Depends on: v0.27.4 (personal task UI). + +- [x] `GET /api/v1/teams/:teamId/tasks` — member-visible team task list +- [x] Team admin CRUD: create/update/delete/run/kill via team-scoped routes +- [x] `canAccessTask` / `canMutateTask` helpers — team member read, team admin write +- [x] Access check on `ListRuns` (was missing — any authenticated user could read run history) +- [x] Settings Tasks section shows team tasks alongside personal (with team badge) +- [x] Sidebar Tasks section includes team tasks (with team label) +- [x] `CreateTeamTask` handler — injects team scope on creation diff --git a/server/handlers/tasks.go b/server/handlers/tasks.go index d0c60bc..636b2ca 100644 --- a/server/handlers/tasks.go +++ b/server/handlers/tasks.go @@ -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 } diff --git a/server/main.go b/server/main.go index c948862..54cf1a0 100644 --- a/server/main.go +++ b/server/main.go @@ -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) diff --git a/server/pages/templates/surfaces/chat.html b/server/pages/templates/surfaces/chat.html index b99c530..fda7854 100644 --- a/server/pages/templates/surfaces/chat.html +++ b/server/pages/templates/surfaces/chat.html @@ -532,5 +532,6 @@ window.addEventListener('unhandledrejection', function(e) { + {{end}} diff --git a/server/pages/templates/surfaces/settings.html b/server/pages/templates/surfaces/settings.html index 0bfe108..80a1d14 100644 --- a/server/pages/templates/surfaces/settings.html +++ b/server/pages/templates/surfaces/settings.html @@ -31,6 +31,7 @@ Profile Teams Workflows + Tasks {{/* BYOK-gated nav items */}}