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 @@
ProfileTeamsWorkflows
+ Tasks
{{/* BYOK-gated nav items */}}
@@ -56,7 +57,7 @@
{{/* Content */}}
-
{{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}}
+
{{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}}
{{if eq .Section "general"}}
@@ -154,6 +155,8 @@
Loading teams…
{{else if eq .Section "workflows"}}
Loading workflows…
+ {{else if eq .Section "tasks"}}
+
Loading tasks…
{{else}}
Loading…
{{end}}
@@ -169,6 +172,7 @@
{{define "scripts-settings"}}
+
@@ -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.
diff --git a/server/scheduler/executor.go b/server/scheduler/executor.go
index 56e3b87..07b320a 100644
--- a/server/scheduler/executor.go
+++ b/server/scheduler/executor.go
@@ -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 ───────────
diff --git a/server/taskutil/task_config.go b/server/taskutil/task_config.go
index ab99821..66386b8 100644
--- a/server/taskutil/task_config.go
+++ b/server/taskutil/task_config.go
@@ -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
}
diff --git a/src/js/task-admin.js b/src/js/task-admin.js
index 4d79873..32b9d14 100644
--- a/src/js/task-admin.js
+++ b/src/js/task-admin.js
@@ -25,6 +25,7 @@
'
' +
'' +
'' +
+ '' +
'
' +
'
' +
'
' +
@@ -74,7 +75,7 @@
async function loadTasks() {
try {
- var resp = await App.api.get('/api/v1/admin/tasks');
+ var resp = await API._get('/api/v1/admin/tasks');
var tasks = resp.data || [];
var list = document.getElementById('taskAdminList');
document.getElementById('taskCount').textContent = tasks.length;
@@ -127,21 +128,21 @@
async function triggerRun(taskId) {
try {
- await App.api.post('/api/v1/admin/tasks/' + taskId + '/run', {});
- App.showToast('Task scheduled for immediate execution', 'success');
+ await API._post('/api/v1/admin/tasks/' + taskId + '/run', {});
+ UI.toast('Task scheduled for immediate execution', 'success');
} catch (err) {
- App.showToast(err.message || 'Failed to trigger run', 'error');
+ UI.toast(err.message || 'Failed to trigger run', 'error');
}
}
async function deleteTask(taskId) {
if (!confirm('Delete this task and all run history?')) return;
try {
- await App.api.delete('/api/v1/admin/tasks/' + taskId);
- App.showToast('Task deleted', 'success');
+ await API._delete('/api/v1/admin/tasks/' + taskId);
+ UI.toast('Task deleted', 'success');
loadTasks();
} catch (err) {
- App.showToast(err.message || 'Failed to delete task', 'error');
+ UI.toast(err.message || 'Failed to delete task', 'error');
}
}
@@ -152,7 +153,7 @@
document.getElementById('taskRunTitle').textContent = 'Run History: ' + taskName;
showTab('runs');
try {
- var resp = await App.api.get('/api/v1/tasks/' + taskId + '/runs');
+ var resp = await API._get('/api/v1/tasks/' + taskId + '/runs');
var runs = resp.data || [];
var list = document.getElementById('taskRunList');
@@ -190,20 +191,21 @@
async function killRun() {
if (!_currentTaskId) return;
try {
- await App.api.post('/api/v1/admin/tasks/' + _currentTaskId + '/kill', {});
- App.showToast('Active run cancelled', 'success');
+ await API._post('/api/v1/admin/tasks/' + _currentTaskId + '/kill', {});
+ UI.toast('Active run cancelled', 'success');
showRuns(_currentTaskId, document.getElementById('taskRunTitle').textContent.replace('Run History: ', ''));
} catch (err) {
- App.showToast(err.message || 'Failed to kill run', 'error');
+ UI.toast(err.message || 'Failed to kill run', 'error');
}
}
async function loadConfig() {
try {
- var resp = await App.api.get('/api/v1/admin/settings/tasks');
+ var resp = await API._get('/api/v1/admin/settings/tasks');
var cfg = resp || {};
document.getElementById('taskCfgEnabled').checked = cfg.enabled !== false;
document.getElementById('taskCfgPersonal').checked = cfg.allow_personal !== false;
+ document.getElementById('taskCfgRequireBYOK').checked = cfg.personal_require_byok === true;
document.getElementById('taskCfgMaxConcurrent').value = cfg.max_concurrent || 5;
document.getElementById('taskCfgMaxTokens').value = cfg.default_max_tokens || 4096;
document.getElementById('taskCfgMaxToolCalls').value = cfg.default_max_tool_calls || 10;
@@ -211,6 +213,7 @@
} catch (_) {
document.getElementById('taskCfgEnabled').checked = true;
document.getElementById('taskCfgPersonal').checked = true;
+ document.getElementById('taskCfgRequireBYOK').checked = false;
document.getElementById('taskCfgMaxConcurrent').value = 5;
document.getElementById('taskCfgMaxTokens').value = 4096;
document.getElementById('taskCfgMaxToolCalls').value = 10;
@@ -223,15 +226,16 @@
var payload = {
enabled: document.getElementById('taskCfgEnabled').checked,
allow_personal: document.getElementById('taskCfgPersonal').checked,
+ personal_require_byok: document.getElementById('taskCfgRequireBYOK').checked,
max_concurrent: parseInt(document.getElementById('taskCfgMaxConcurrent').value) || 5,
default_max_tokens: parseInt(document.getElementById('taskCfgMaxTokens').value) || 4096,
default_max_tool_calls: parseInt(document.getElementById('taskCfgMaxToolCalls').value) || 10,
default_max_wall_clock: parseInt(document.getElementById('taskCfgMaxWallClock').value) || 300
};
- await App.api.put('/api/v1/admin/settings/tasks', payload);
- App.showToast('Task configuration saved', 'success');
+ await API._put('/api/v1/admin/settings/tasks', payload);
+ UI.toast('Task configuration saved', 'success');
} catch (err) {
- App.showToast(err.message || 'Failed to save config', 'error');
+ UI.toast(err.message || 'Failed to save config', 'error');
}
}
diff --git a/src/js/task-settings.js b/src/js/task-settings.js
new file mode 100644
index 0000000..5686a60
--- /dev/null
+++ b/src/js/task-settings.js
@@ -0,0 +1,281 @@
+// ==========================================
+// Chat Switchboard — Task Settings (User)
+// ==========================================
+// Settings → Tasks section. CRUD for personal tasks with schedule builder,
+// persona/model picker, budget config, and starter templates.
+// Loaded on the settings surface.
+
+(function() {
+ 'use strict';
+
+ const PRESETS = [
+ { label: 'Every morning (6am)', cron: '0 6 * * *' },
+ { label: 'Weekday mornings (8am)', cron: '0 8 * * 1-5' },
+ { label: 'Every hour', cron: '0 * * * *' },
+ { label: 'Weekly (Monday 9am)', cron: '0 9 * * 1' },
+ { label: 'Monthly (1st midnight)', cron: '0 0 1 * *' },
+ { label: 'Custom...', cron: '' },
+ ];
+
+ const TEMPLATES = [
+ { name: 'Morning News Digest', prompt: 'Summarize the top 5 tech news headlines from today. Be concise — one paragraph per story.', schedule: '0 6 * * *' },
+ { name: 'Daily Standup Prep', prompt: 'Review my recent notes and conversations. Generate 3 concise standup talking points covering what I worked on yesterday, what I plan today, and any blockers.', schedule: '0 8 * * 1-5' },
+ { name: 'Weekly Project Summary', prompt: 'Summarize this week\'s activity across my projects. Highlight completed items, open threads, and priorities for next week.', schedule: '0 17 * * 5' },
+ { name: 'Stock Watchlist Check', prompt: 'Check for unusual pre-market activity on major tech stocks (AAPL, GOOGL, MSFT, NVDA, AMZN). Flag any moves > 2%.', schedule: '0 9 * * 1-5' },
+ { name: 'Research Digest', prompt: 'Search for the latest papers and blog posts on AI agents and autonomous systems. Summarize the 3 most interesting findings.', schedule: '0 12 * * 1' },
+ ];
+
+ function esc(s) { return s ? s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"') : ''; }
+
+ // Detect browser timezone
+ function browserTZ() {
+ try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch (_) { return 'UTC'; }
+ }
+
+ // ── Task list ────────────────────────────
+ async function loadTaskList() {
+ var mount = document.getElementById('settingsTasksMount');
+ if (!mount) return;
+
+ try {
+ // Load personal tasks
+ var resp = await API._get('/api/v1/tasks');
+ var personalTasks = (resp.data || []).map(function(t) { t._source = 'personal'; return t; });
+
+ // Load team tasks from all user's teams
+ var teamTasks = [];
+ try {
+ var teamsResp = await API._get('/api/v1/teams/mine');
+ var teams = teamsResp.data || teamsResp || [];
+ for (var i = 0; i < teams.length; i++) {
+ try {
+ var tr = await API._get('/api/v1/teams/' + teams[i].id + '/tasks');
+ (tr.data || []).forEach(function(t) {
+ t._source = 'team';
+ t._teamName = teams[i].name;
+ teamTasks.push(t);
+ });
+ } catch (_) { /* team may not have tasks */ }
+ }
+ } catch (_) { /* no teams */ }
+
+ // Deduplicate (user might own a team task)
+ var seen = {};
+ personalTasks.forEach(function(t) { seen[t.id] = true; });
+ teamTasks = teamTasks.filter(function(t) { return !seen[t.id]; });
+
+ var tasks = personalTasks.concat(teamTasks);
+
+ var html = '
' +
+ '
Your scheduled tasks. Tasks run automatically on their schedule.
' +
+ '' +
+ '
';
+
+ if (tasks.length > 0) {
+ html += tasks.map(function(t) {
+ var status = t.is_active ? 'active' : 'paused';
+ var teamBadge = t._source === 'team' ? '' + esc(t._teamName || 'team') + '' : '';
+ var sched = t.schedule === 'once' ? 'One-shot' : t.schedule;
+ var lastStatus = '';
+ if (t.last_run_at) {
+ lastStatus = ' \u00b7 last: ' + new Date(t.last_run_at).toLocaleString();
+ }
+ var nextRun = t.next_run_at ? new Date(t.next_run_at).toLocaleString() : '\u2014';
+
+ return '