Changeset 0.27.4 (#171)
This commit is contained in:
83
CHANGELOG.md
83
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
|
||||
|
||||
@@ -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
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
'<div class="form-row" style="gap:12px">' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="taskCfgEnabled"><span class="toggle-track"></span><span>Tasks Enabled</span></label>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="taskCfgPersonal"><span class="toggle-track"></span><span>Allow Personal Tasks</span></label>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="taskCfgRequireBYOK"><span class="toggle-track"></span><span>Require BYOK for Personal</span></label>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:12px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Max Concurrent</label>' +
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
281
src/js/task-settings.js
Normal file
281
src/js/task-settings.js
Normal file
@@ -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,'>').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 = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">' +
|
||||
'<p style="color:var(--text-2);font-size:13px;margin:0">Your scheduled tasks. Tasks run automatically on their schedule.</p>' +
|
||||
'<button class="btn-small btn-primary" id="taskNewBtn">+ New Task</button>' +
|
||||
'</div>';
|
||||
|
||||
if (tasks.length > 0) {
|
||||
html += tasks.map(function(t) {
|
||||
var status = t.is_active ? '<span class="badge badge-success">active</span>' : '<span class="badge badge-muted">paused</span>';
|
||||
var teamBadge = t._source === 'team' ? '<span class="badge" style="background:var(--accent-dim);color:var(--accent)">' + esc(t._teamName || 'team') + '</span>' : '';
|
||||
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 '<div class="settings-section" style="padding:12px 16px;margin-bottom:8px">' +
|
||||
'<div style="display:flex;align-items:center;gap:12px">' +
|
||||
'<div style="flex:1">' +
|
||||
'<div style="font-weight:600;font-size:14px">' + esc(t.name) + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--text-2);margin-top:2px">' + esc(sched) + ' (' + esc(t.timezone) + ')' + lastStatus + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--text-2)">next: ' + nextRun + '</div>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:6px;align-items:center">' +
|
||||
teamBadge + status +
|
||||
'<button class="btn-small task-run-btn" data-id="' + t.id + '" title="Run Now">\u25b6</button>' +
|
||||
'<button class="btn-small task-toggle-btn" data-id="' + t.id + '" data-active="' + t.is_active + '" title="' + (t.is_active ? 'Pause' : 'Resume') + '">' + (t.is_active ? '\u23f8' : '\u25b6') + '</button>' +
|
||||
'<button class="btn-small btn-danger task-del-btn" data-id="' + t.id + '" title="Delete">\u2715</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Templates section
|
||||
html += '<div style="margin-top:24px"><h4 style="margin:0 0 8px">Starter Templates</h4>' +
|
||||
'<p style="color:var(--text-2);font-size:12px;margin:0 0 12px">Quick-start with a pre-configured task. You can customize after creation.</p>' +
|
||||
'<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px">';
|
||||
TEMPLATES.forEach(function(tmpl) {
|
||||
html += '<div class="settings-section" style="padding:10px 12px;cursor:pointer" onclick="window._createFromTemplate(\'' + esc(tmpl.name) + '\')">' +
|
||||
'<div style="font-weight:600;font-size:13px">' + esc(tmpl.name) + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--text-2);margin-top:4px">' + esc(tmpl.schedule) + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
|
||||
mount.innerHTML = html;
|
||||
|
||||
// Wire buttons
|
||||
document.getElementById('taskNewBtn').addEventListener('click', function() { showCreateForm(); });
|
||||
mount.querySelectorAll('.task-run-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { runTask(btn.dataset.id); });
|
||||
});
|
||||
mount.querySelectorAll('.task-toggle-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { toggleTask(btn.dataset.id, btn.dataset.active === 'true'); });
|
||||
});
|
||||
mount.querySelectorAll('.task-del-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { deleteTask(btn.dataset.id); });
|
||||
});
|
||||
} catch (err) {
|
||||
mount.innerHTML = '<p style="color:var(--danger)">Failed to load tasks: ' + esc(err.message) + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create form ──────────────────────────
|
||||
function showCreateForm(defaults) {
|
||||
var d = defaults || {};
|
||||
var mount = document.getElementById('settingsTasksMount');
|
||||
if (!mount) return;
|
||||
|
||||
var schedOpts = PRESETS.map(function(p) {
|
||||
var sel = (d.schedule && d.schedule === p.cron) ? ' selected' : '';
|
||||
return '<option value="' + esc(p.cron) + '"' + sel + '>' + esc(p.label) + '</option>';
|
||||
}).join('');
|
||||
|
||||
mount.innerHTML =
|
||||
'<div style="margin-bottom:16px"><button class="btn-small" id="taskBackBtn">\u2190 Back</button></div>' +
|
||||
'<div class="settings-section" style="padding:16px">' +
|
||||
'<div class="form-row" style="gap:12px">' +
|
||||
'<div class="form-group" style="flex:2"><label>Name</label>' +
|
||||
'<input type="text" id="taskName" value="' + esc(d.name || '') + '" style="width:100%" placeholder="Morning News Digest"></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Model</label>' +
|
||||
'<input type="text" id="taskModel" value="' + esc(d.model || '') + '" style="width:100%" placeholder="(use default)"></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-group" style="margin-top:8px"><label>Prompt</label>' +
|
||||
'<textarea id="taskPrompt" rows="4" style="width:100%">' + esc(d.prompt || '') + '</textarea></div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Schedule</label>' +
|
||||
'<select id="taskPreset" style="width:100%">' + schedOpts + '</select></div>' +
|
||||
'<div class="form-group" style="flex:1" id="taskCustomCronGroup" style="display:none"><label>Custom Cron</label>' +
|
||||
'<input type="text" id="taskCron" value="' + esc(d.schedule || '') + '" style="width:100%" placeholder="0 6 * * *"></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Timezone</label>' +
|
||||
'<input type="text" id="taskTZ" value="' + esc(d.timezone || browserTZ()) + '" style="width:100%"></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Output Mode</label>' +
|
||||
'<select id="taskOutput" style="width:100%"><option value="channel">Channel</option><option value="note">Note</option><option value="webhook">Webhook</option></select></div>' +
|
||||
'<div class="form-group" style="flex:1" id="taskWebhookGroup" style="display:none"><label>Webhook URL</label>' +
|
||||
'<input type="text" id="taskWebhookURL" style="width:100%" placeholder="https://..."></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Max Tokens</label>' +
|
||||
'<input type="number" id="taskMaxTokens" value="" style="width:100%" placeholder="default"></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Max Tool Calls</label>' +
|
||||
'<input type="number" id="taskMaxToolCalls" value="" style="width:100%" placeholder="default"></div>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:8px;margin-top:12px;align-items:center">' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="taskNotifyComplete"><span class="toggle-track"></span><span>Notify on complete</span></label>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="taskNotifyFail" checked><span class="toggle-track"></span><span>Notify on failure</span></label>' +
|
||||
'</div>' +
|
||||
'<button class="btn-small btn-primary" id="taskCreateBtn" style="margin-top:12px">Create Task</button>' +
|
||||
'</div>';
|
||||
|
||||
document.getElementById('taskBackBtn').addEventListener('click', loadTaskList);
|
||||
document.getElementById('taskCreateBtn').addEventListener('click', createTask);
|
||||
|
||||
// Toggle custom cron visibility
|
||||
var preset = document.getElementById('taskPreset');
|
||||
var customGroup = document.getElementById('taskCustomCronGroup');
|
||||
preset.addEventListener('change', function() {
|
||||
customGroup.style.display = preset.value === '' ? '' : 'none';
|
||||
if (preset.value !== '') document.getElementById('taskCron').value = preset.value;
|
||||
});
|
||||
// Initial state
|
||||
customGroup.style.display = preset.value === '' ? '' : 'none';
|
||||
if (preset.value !== '') document.getElementById('taskCron').value = preset.value;
|
||||
|
||||
// Toggle webhook URL visibility
|
||||
var output = document.getElementById('taskOutput');
|
||||
var webhookGroup = document.getElementById('taskWebhookGroup');
|
||||
output.addEventListener('change', function() {
|
||||
webhookGroup.style.display = output.value === 'webhook' ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
async function createTask() {
|
||||
var schedule = document.getElementById('taskCron').value || document.getElementById('taskPreset').value;
|
||||
if (!schedule) { UI.toast('Schedule is required', 'error'); return; }
|
||||
|
||||
var payload = {
|
||||
name: document.getElementById('taskName').value,
|
||||
task_type: 'prompt',
|
||||
user_prompt: document.getElementById('taskPrompt').value,
|
||||
schedule: schedule,
|
||||
timezone: document.getElementById('taskTZ').value || 'UTC',
|
||||
output_mode: document.getElementById('taskOutput').value,
|
||||
notify_on_complete: document.getElementById('taskNotifyComplete').checked,
|
||||
notify_on_failure: document.getElementById('taskNotifyFail').checked,
|
||||
};
|
||||
|
||||
var model = document.getElementById('taskModel').value;
|
||||
if (model) payload.model_id = model;
|
||||
|
||||
var maxTokens = parseInt(document.getElementById('taskMaxTokens').value);
|
||||
if (maxTokens > 0) payload.max_tokens = maxTokens;
|
||||
|
||||
var maxTools = parseInt(document.getElementById('taskMaxToolCalls').value);
|
||||
if (maxTools > 0) payload.max_tool_calls = maxTools;
|
||||
|
||||
var webhookURL = document.getElementById('taskWebhookURL')?.value;
|
||||
if (payload.output_mode === 'webhook' && webhookURL) {
|
||||
payload.webhook_url = webhookURL;
|
||||
}
|
||||
|
||||
try {
|
||||
await API._post('/api/v1/tasks', payload);
|
||||
UI.toast('Task created', 'success');
|
||||
loadTaskList();
|
||||
// Refresh sidebar
|
||||
if (typeof TaskSidebar !== 'undefined') TaskSidebar.refresh();
|
||||
} catch (err) {
|
||||
UI.toast(err.message || 'Failed to create task', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ──────────────────────────────
|
||||
async function runTask(id) {
|
||||
try {
|
||||
await API._post('/api/v1/tasks/' + id + '/run', {});
|
||||
UI.toast('Task scheduled for immediate run', 'success');
|
||||
} catch (err) { UI.toast(err.message || 'Failed', 'error'); }
|
||||
}
|
||||
|
||||
async function toggleTask(id, isActive) {
|
||||
try {
|
||||
await API._put('/api/v1/tasks/' + id, { is_active: !isActive });
|
||||
UI.toast(isActive ? 'Task paused' : 'Task resumed', 'success');
|
||||
loadTaskList();
|
||||
if (typeof TaskSidebar !== 'undefined') TaskSidebar.refresh();
|
||||
} catch (err) { UI.toast(err.message || 'Failed', 'error'); }
|
||||
}
|
||||
|
||||
async function deleteTask(id) {
|
||||
if (!confirm('Delete this task?')) return;
|
||||
try {
|
||||
await API._delete('/api/v1/tasks/' + id);
|
||||
UI.toast('Task deleted', 'success');
|
||||
loadTaskList();
|
||||
if (typeof TaskSidebar !== 'undefined') TaskSidebar.refresh();
|
||||
} catch (err) { UI.toast(err.message || 'Failed', 'error'); }
|
||||
}
|
||||
|
||||
// Template launcher
|
||||
window._createFromTemplate = function(name) {
|
||||
var tmpl = TEMPLATES.find(function(t) { return t.name === name; });
|
||||
if (tmpl) showCreateForm({ name: tmpl.name, prompt: tmpl.prompt, schedule: tmpl.schedule });
|
||||
};
|
||||
|
||||
// ── Entry point (called from settings loader) ──
|
||||
window._loadSettingsTasks = function() {
|
||||
loadTaskList();
|
||||
};
|
||||
})();
|
||||
143
src/js/task-sidebar.js
Normal file
143
src/js/task-sidebar.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Task Sidebar Section
|
||||
// ==========================================
|
||||
// Sidebar section showing user's scheduled tasks with status indicators.
|
||||
// Click opens the task's output channel. Same pattern as WorkflowQueue.
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
window.TaskSidebar = {
|
||||
|
||||
_loaded: false,
|
||||
|
||||
// ── Init ────────────────────────────
|
||||
|
||||
async init() {
|
||||
if (this._loaded) return;
|
||||
this._loaded = true;
|
||||
|
||||
// Insert after Queue section (or after Channels if no queue)
|
||||
var anchor = document.getElementById('sbSectionQueue') ||
|
||||
document.getElementById('sbSectionChannels');
|
||||
if (!anchor) return;
|
||||
|
||||
var section = document.createElement('div');
|
||||
section.className = 'sb-section';
|
||||
section.id = 'sbSectionTasks';
|
||||
section.innerHTML =
|
||||
'<div class="sb-section-header" onclick="UI.toggleSidebarSection(\'tasks\')">' +
|
||||
'<span class="sb-section-arrow" id="sbArrowTasks">\u25be</span>' +
|
||||
'<span class="sb-section-label">Tasks</span>' +
|
||||
'<span class="sb-queue-badge" id="sbTaskBadge" style="display:none">0</span>' +
|
||||
'</div>' +
|
||||
'<div class="sb-section-body" id="sbBodyTasks"></div>';
|
||||
|
||||
anchor.parentNode.insertBefore(section, anchor.nextSibling);
|
||||
this.refresh();
|
||||
},
|
||||
|
||||
// ── Refresh ─────────────────────────
|
||||
|
||||
async refresh() {
|
||||
var body = document.getElementById('sbBodyTasks');
|
||||
var badge = document.getElementById('sbTaskBadge');
|
||||
if (!body) return;
|
||||
|
||||
try {
|
||||
// Personal tasks
|
||||
var resp = await API._get('/api/v1/tasks');
|
||||
var tasks = (resp.data || []).filter(function(t) { return t.is_active; });
|
||||
|
||||
// Team tasks (v0.27.5)
|
||||
try {
|
||||
var teamsResp = await API._get('/api/v1/teams/mine');
|
||||
var teams = teamsResp.data || teamsResp || [];
|
||||
var seen = {};
|
||||
tasks.forEach(function(t) { seen[t.id] = true; });
|
||||
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) {
|
||||
if (!seen[t.id] && t.is_active) {
|
||||
t._teamName = teams[i].name;
|
||||
tasks.push(t);
|
||||
seen[t.id] = true;
|
||||
}
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
body.innerHTML = '';
|
||||
if (badge) badge.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (badge) {
|
||||
badge.textContent = tasks.length;
|
||||
badge.style.display = '';
|
||||
}
|
||||
|
||||
var html = '';
|
||||
tasks.forEach(function(t) {
|
||||
// Status indicator
|
||||
var icon = '\u25f7'; // default: clock
|
||||
var statusTip = 'Scheduled';
|
||||
if (t.last_run_at) {
|
||||
// Check if most recent run was recent (within 2x schedule interval)
|
||||
icon = '\u2713'; // checkmark
|
||||
statusTip = 'Last: ' + new Date(t.last_run_at).toLocaleString();
|
||||
}
|
||||
var nextTip = t.next_run_at
|
||||
? 'Next: ' + new Date(t.next_run_at).toLocaleString()
|
||||
: '';
|
||||
|
||||
var channelId = t.output_channel_id || '';
|
||||
var onclick = channelId
|
||||
? 'selectChannel(\'' + channelId + '\')'
|
||||
: 'UI.toast(\'No output channel yet — task has not run\',\'info\')';
|
||||
|
||||
var label = _esc(t.name);
|
||||
if (t._teamName) label = '<span style="opacity:0.6;font-size:10px">[' + _esc(t._teamName) + ']</span> ' + label;
|
||||
|
||||
html += '<div class="sb-queue-item" onclick="' + onclick + '" title="' +
|
||||
_esc(statusTip + (nextTip ? ' \u00b7 ' + nextTip : '')) + '">' +
|
||||
'<span class="sb-queue-icon">' + icon + '</span>' +
|
||||
'<span class="sb-queue-label">' + label + '</span>' +
|
||||
'<button class="btn-small" style="padding:0 4px;font-size:10px" ' +
|
||||
'onclick="event.stopPropagation();TaskSidebar.runNow(\'' + t.id + '\')" title="Run Now">\u25b6</button>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
body.innerHTML = html;
|
||||
} catch (err) {
|
||||
body.innerHTML = '';
|
||||
if (badge) badge.style.display = 'none';
|
||||
}
|
||||
},
|
||||
|
||||
// ── Run Now ─────────────────────────
|
||||
|
||||
async runNow(taskId) {
|
||||
try {
|
||||
await API._post('/api/v1/tasks/' + taskId + '/run', {});
|
||||
UI.toast('Task triggered', 'success');
|
||||
} catch (err) {
|
||||
UI.toast(err.message || 'Failed to run task', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function _esc(s) { return s ? s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : ''; }
|
||||
|
||||
// Auto-init when chat surface loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setTimeout(function() {
|
||||
if (typeof API !== 'undefined' && API.accessToken) {
|
||||
TaskSidebar.init();
|
||||
}
|
||||
}, 2200); // After WorkflowQueue (2000ms)
|
||||
});
|
||||
})();
|
||||
@@ -630,6 +630,9 @@ const UI = {
|
||||
|
||||
// Refresh workflow queue section (workflow channels filtered out of chat list)
|
||||
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
|
||||
|
||||
// Refresh task sidebar section (v0.27.4)
|
||||
if (typeof TaskSidebar !== 'undefined') TaskSidebar.refresh();
|
||||
},
|
||||
|
||||
toggleFolder(folderId) {
|
||||
|
||||
Reference in New Issue
Block a user