From 5eacd08c850d25acb45393345ee410a92244128f Mon Sep 17 00:00:00 2001 From: wanghj Date: Tue, 7 Jul 2026 14:15:07 +0800 Subject: [PATCH 1/6] feat(tasks): add scheduled task automation --- cli/serve/serve.go | 19 + internal/agenttask/service.go | 2 + internal/api/handler.go | 8 + internal/api/rest_handlers.go | 24 + internal/api/router.go | 10 + internal/api/scheduled_task.go | 229 +++++++++ internal/api/scheduled_task_test.go | 74 +++ internal/apitypes/team.go | 43 ++ internal/config/config.go | 4 + internal/scheduledtask/model.go | 64 +++ internal/scheduledtask/service.go | 446 +++++++++++++++++ internal/scheduledtask/service_test.go | 275 +++++++++++ internal/scheduledtask/store.go | 87 ++++ internal/server/http.go | 6 + web/app/src/api/scheduledTasks.ts | 89 ++++ .../src/hooks/workspace/useTaskController.ts | 108 ++++- web/app/src/models/scheduledTasks.ts | 124 +++++ .../components/TasksView/TasksView.module.css | 204 ++++++++ .../components/TasksView/TasksView.tsx | 448 +++++++++++++++++- web/app/src/shared/i18n/messages.ts | 78 +++ 20 files changed, 2340 insertions(+), 2 deletions(-) create mode 100644 internal/api/scheduled_task.go create mode 100644 internal/api/scheduled_task_test.go create mode 100644 internal/scheduledtask/model.go create mode 100644 internal/scheduledtask/service.go create mode 100644 internal/scheduledtask/service_test.go create mode 100644 internal/scheduledtask/store.go create mode 100644 web/app/src/api/scheduledTasks.ts create mode 100644 web/app/src/models/scheduledTasks.ts diff --git a/cli/serve/serve.go b/cli/serve/serve.go index 51c6bcc9..2be4af09 100644 --- a/cli/serve/serve.go +++ b/cli/serve/serve.go @@ -44,6 +44,7 @@ import ( "csgclaw/internal/participant" runtimecodex "csgclaw/internal/runtime/codex" "csgclaw/internal/sandboxproviders" + "csgclaw/internal/scheduledtask" "csgclaw/internal/server" "csgclaw/internal/taskcore" "csgclaw/internal/team" @@ -60,6 +61,7 @@ var ( NewLLMService = newLLMService NewTeamService = newTeamService NewAgentTaskService = newAgentTaskService + NewScheduledTaskService = newScheduledTaskService CheckModelProvider = checkModelProvider CheckCatalogModelProvider = agent.CheckModelProvider EnsureCLIProxy = func(ctx context.Context) error { @@ -599,6 +601,10 @@ func startServerWithConfigPath(ctx context.Context, run *command.Context, cfg co if err != nil { return err } + scheduledTaskSvc, err := NewScheduledTaskService(agentTaskSvc) + if err != nil { + return err + } ensureBootstrapManager := EnsureBootstrapManager startConfiguredAgents := StartConfiguredAgents return RunServer(server.Options{ @@ -613,6 +619,7 @@ func startServerWithConfigPath(ctx context.Context, run *command.Context, cfg co LLM: llmSvc, Team: teamSvc, AgentTask: agentTaskSvc, + ScheduledTask: scheduledTaskSvc, TeamAdapters: teamAdapters, Upgrade: upgradeManager, ActivityDecider: channelActivityDecider(codexBridgeMgr), @@ -1113,6 +1120,18 @@ func newAgentTaskService(agentSvc *agent.Service, imSvc *im.Service, participant return agenttask.NewService(core, imSvc, agentSvc, participantSvc), nil } +func newScheduledTaskService(agentTaskSvc *agenttask.Service) (*scheduledtask.Service, error) { + dir, err := config.DefaultScheduledTasksDir() + if err != nil { + return nil, err + } + store, err := scheduledtask.NewStore(dir) + if err != nil { + return nil, err + } + return scheduledtask.NewService(store, agentTaskSvc) +} + func buildFeishuComponents() (feishu.AgentCredentialProvider, *feishu.Service, error) { participantsPath, err := defaultParticipantsPath() if err != nil { diff --git a/internal/agenttask/service.go b/internal/agenttask/service.go index 166d6302..34eab2af 100644 --- a/internal/agenttask/service.go +++ b/internal/agenttask/service.go @@ -24,6 +24,7 @@ type Service struct { } type CreateInput struct { + ID string AgentID string Title string Body string @@ -89,6 +90,7 @@ func (s *Service) CreateAgentTask(ctx context.Context, input CreateInput) (taskc assignedTo := s.participantIDForAgentID(agentID) task, err := s.core.CreateRoot(taskcore.CreateRootInput{ + ID: strings.TrimSpace(input.ID), AssignmentType: taskcore.AssignmentTypeAgent, AssignmentID: agentID, Title: title, diff --git a/internal/api/handler.go b/internal/api/handler.go index 0ad97bde..b946db76 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -29,6 +29,7 @@ import ( agentruntime "csgclaw/internal/runtime" "csgclaw/internal/sandbox" "csgclaw/internal/sandboxproviders" + "csgclaw/internal/scheduledtask" "csgclaw/internal/team" hub "csgclaw/internal/template" "csgclaw/internal/upgrade" @@ -49,6 +50,7 @@ type Handler struct { hub *hub.Service teamSvc *team.Service agentTaskSvc *agenttask.Service + scheduledTaskSvc *scheduledtask.Service connectors *connectors.Service teamAdapters *team.AdapterRegistry teamPlanJobsMu sync.Mutex @@ -704,6 +706,12 @@ func (h *Handler) SetAgentTaskService(svc *agenttask.Service) { } } +func (h *Handler) SetScheduledTaskService(svc *scheduledtask.Service) { + if h != nil { + h.scheduledTaskSvc = svc + } +} + func (h *Handler) SetConnectorService(svc *connectors.Service) { if h != nil { h.connectors = svc diff --git a/internal/api/rest_handlers.go b/internal/api/rest_handlers.go index 00bd8eac..3c3334d9 100644 --- a/internal/api/rest_handlers.go +++ b/internal/api/rest_handlers.go @@ -180,6 +180,30 @@ func (h *Handler) updateAgentTask(w http.ResponseWriter, r *http.Request) { func (h *Handler) listGlobalTasks(w http.ResponseWriter, r *http.Request) { h.handleListGlobalTasks(w, r) } + +func (h *Handler) listScheduledTasks(w http.ResponseWriter, r *http.Request) { + h.handleListScheduledTasks(w, r) +} + +func (h *Handler) createScheduledTask(w http.ResponseWriter, r *http.Request) { + h.handleCreateScheduledTask(w, r) +} + +func (h *Handler) updateScheduledTask(w http.ResponseWriter, r *http.Request) { + h.handleUpdateScheduledTask(w, r) +} + +func (h *Handler) deleteScheduledTask(w http.ResponseWriter, r *http.Request) { + h.handleDeleteScheduledTask(w, r) +} + +func (h *Handler) listScheduledTaskRuns(w http.ResponseWriter, r *http.Request) { + h.handleListScheduledTaskRuns(w, r) +} + +func (h *Handler) runScheduledTaskNow(w http.ResponseWriter, r *http.Request) { + h.handleRunScheduledTaskNow(w, r) +} func (h *Handler) listIMMessages(w http.ResponseWriter, r *http.Request) { h.handleIMMessages(w, r) } func (h *Handler) createIMMessage(w http.ResponseWriter, r *http.Request) { h.handleIMMessages(w, r) } func (h *Handler) listIMConversations(w http.ResponseWriter, r *http.Request) { h.handleIMRooms(w, r) } diff --git a/internal/api/router.go b/internal/api/router.go index 747e68a1..9e0fdbd0 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -172,6 +172,16 @@ func (h *Handler) registerCoreRoutes(router chi.Router) { r.Patch("/", h.updateAgentTask) }) }) + r.Route("/scheduled-tasks", func(r chi.Router) { + r.Get("/", h.listScheduledTasks) + r.Post("/", h.createScheduledTask) + r.Route("/{scheduled_task_id}", func(r chi.Router) { + r.Patch("/", h.updateScheduledTask) + r.Delete("/", h.deleteScheduledTask) + r.Get("/runs", h.listScheduledTaskRuns) + r.Post("/run-now", h.runScheduledTaskNow) + }) + }) r.Get("/tasks", h.listGlobalTasks) }) } diff --git a/internal/api/scheduled_task.go b/internal/api/scheduled_task.go new file mode 100644 index 00000000..7ecfa804 --- /dev/null +++ b/internal/api/scheduled_task.go @@ -0,0 +1,229 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "csgclaw/internal/apitypes" + "csgclaw/internal/scheduledtask" +) + +func (h *Handler) requireScheduledTaskService(w http.ResponseWriter) (*scheduledtask.Service, bool) { + if h == nil || h.scheduledTaskSvc == nil { + http.Error(w, "scheduled task service is not configured", http.StatusServiceUnavailable) + return nil, false + } + return h.scheduledTaskSvc, true +} + +func (h *Handler) handleListScheduledTasks(w http.ResponseWriter, r *http.Request) { + svc, ok := h.requireScheduledTaskService(w) + if !ok { + return + } + items := svc.List() + resp := make([]apitypes.ScheduledTask, 0, len(items)) + for _, item := range items { + resp = append(resp, apiScheduledTask(item)) + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *Handler) handleCreateScheduledTask(w http.ResponseWriter, r *http.Request) { + svc, ok := h.requireScheduledTaskService(w) + if !ok { + return + } + var req apitypes.CreateScheduledTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) + return + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + item, err := svc.Create(scheduledtask.CreateInput{ + Title: strings.TrimSpace(req.Title), + AgentID: strings.TrimSpace(req.AgentID), + Prompt: strings.TrimSpace(req.Prompt), + Recurrence: strings.TrimSpace(req.Recurrence), + FirstRunAt: req.FirstRunAt.In(time.Local), + ExpiresAt: localTimePtr(req.ExpiresAt), + Enabled: enabled, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusCreated, apiScheduledTask(item)) +} + +func (h *Handler) handleUpdateScheduledTask(w http.ResponseWriter, r *http.Request) { + svc, ok := h.requireScheduledTaskService(w) + if !ok { + return + } + var req patchScheduledTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) + return + } + item, err := svc.Update(scheduledtask.UpdateInput{ + ID: pathValue(r, "scheduled_task_id"), + Title: req.Title, + AgentID: req.AgentID, + Prompt: req.Prompt, + Recurrence: req.Recurrence, + NextRunAt: localTimeValuePtr(req.NextRunAt), + ExpiresAt: localOptionalTimeDoublePtr(req.ExpiresAt), + Enabled: req.Enabled, + }) + if err != nil { + writeScheduledTaskError(w, err) + return + } + writeJSON(w, http.StatusOK, apiScheduledTask(item)) +} + +func (h *Handler) handleDeleteScheduledTask(w http.ResponseWriter, r *http.Request) { + svc, ok := h.requireScheduledTaskService(w) + if !ok { + return + } + if err := svc.Delete(pathValue(r, "scheduled_task_id")); err != nil { + writeScheduledTaskError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) handleListScheduledTaskRuns(w http.ResponseWriter, r *http.Request) { + svc, ok := h.requireScheduledTaskService(w) + if !ok { + return + } + runs := svc.Runs(pathValue(r, "scheduled_task_id")) + resp := make([]apitypes.ScheduledTaskRun, 0, len(runs)) + for _, run := range runs { + resp = append(resp, apiScheduledTaskRun(run)) + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *Handler) handleRunScheduledTaskNow(w http.ResponseWriter, r *http.Request) { + svc, ok := h.requireScheduledTaskService(w) + if !ok { + return + } + run, err := svc.RunNow(r.Context(), pathValue(r, "scheduled_task_id")) + if err != nil { + writeScheduledTaskError(w, err) + return + } + writeJSON(w, http.StatusCreated, apiScheduledTaskRun(run)) +} + +func writeScheduledTaskError(w http.ResponseWriter, err error) { + if errors.Is(err, scheduledtask.ErrActiveTask) { + http.Error(w, err.Error(), http.StatusConflict) + return + } + if strings.Contains(strings.ToLower(err.Error()), "not found") { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusBadRequest) +} + +func apiScheduledTask(item scheduledtask.Task) apitypes.ScheduledTask { + return apitypes.ScheduledTask{ + ID: item.ID, + Title: item.Title, + AgentID: item.AgentID, + Prompt: item.Prompt, + Recurrence: item.Recurrence, + Enabled: item.Enabled, + NextRunAt: item.NextRunAt, + LastRunAt: item.LastRunAt, + ExpiresAt: item.ExpiresAt, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +func apiScheduledTaskRun(run scheduledtask.Run) apitypes.ScheduledTaskRun { + return apitypes.ScheduledTaskRun{ + ID: run.ID, + ScheduledTaskID: run.ScheduledTaskID, + TriggeredAt: run.TriggeredAt, + Status: run.Status, + TaskID: run.TaskID, + Error: run.Error, + } +} + +func localTimePtr(value *time.Time) *time.Time { + if value == nil { + return nil + } + next := value.In(time.Local) + return &next +} + +func localTimeValuePtr(value *time.Time) *time.Time { + return localTimePtr(value) +} + +func localTimeDoublePtr(value *time.Time) **time.Time { + if value == nil { + return nil + } + next := localTimePtr(value) + return &next +} + +type patchScheduledTaskRequest struct { + Title *string `json:"title,omitempty"` + AgentID *string `json:"agent_id,omitempty"` + Prompt *string `json:"prompt,omitempty"` + Recurrence *string `json:"recurrence,omitempty"` + NextRunAt *time.Time `json:"next_run_at,omitempty"` + ExpiresAt optionalTime `json:"expires_at,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +type optionalTime struct { + set bool + value *time.Time +} + +func (t *optionalTime) UnmarshalJSON(data []byte) error { + t.set = true + if bytes.Equal(bytes.TrimSpace(data), []byte("null")) { + t.value = nil + return nil + } + var value time.Time + if err := json.Unmarshal(data, &value); err != nil { + return err + } + t.value = &value + return nil +} + +func localOptionalTimeDoublePtr(value optionalTime) **time.Time { + if !value.set { + return nil + } + if value.value == nil { + var cleared *time.Time + return &cleared + } + return localTimeDoublePtr(value.value) +} diff --git a/internal/api/scheduled_task_test.go b/internal/api/scheduled_task_test.go new file mode 100644 index 00000000..03688018 --- /dev/null +++ b/internal/api/scheduled_task_test.go @@ -0,0 +1,74 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "csgclaw/internal/apitypes" + "csgclaw/internal/scheduledtask" +) + +func TestScheduledTaskPatchCanClearExpiresAt(t *testing.T) { + store, err := scheduledtask.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("NewStore() error = %v", err) + } + svc, err := scheduledtask.NewService(store, nil) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + h := &Handler{} + h.SetScheduledTaskService(svc) + + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/scheduled-tasks", strings.NewReader(`{ + "title":"Daily check", + "agent_id":"worker", + "prompt":"Report status.", + "recurrence":"daily", + "first_run_at":"2026-07-07T10:40:00+08:00", + "expires_at":"2026-07-31T23:59:00+08:00" + }`)) + createRec := httptest.NewRecorder() + h.Routes().ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusCreated { + t.Fatalf("create status = %d, want %d: %s", createRec.Code, http.StatusCreated, createRec.Body.String()) + } + var created apitypes.ScheduledTask + if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil { + t.Fatalf("decode create response: %v", err) + } + if created.ExpiresAt == nil { + t.Fatalf("created ExpiresAt = nil, want value") + } + + preserveReq := httptest.NewRequest(http.MethodPatch, "/api/v1/scheduled-tasks/"+created.ID, strings.NewReader(`{"enabled":false}`)) + preserveRec := httptest.NewRecorder() + h.Routes().ServeHTTP(preserveRec, preserveReq) + if preserveRec.Code != http.StatusOK { + t.Fatalf("preserve patch status = %d, want %d: %s", preserveRec.Code, http.StatusOK, preserveRec.Body.String()) + } + var preserved apitypes.ScheduledTask + if err := json.NewDecoder(preserveRec.Body).Decode(&preserved); err != nil { + t.Fatalf("decode preserve response: %v", err) + } + if preserved.ExpiresAt == nil { + t.Fatalf("preserved ExpiresAt = nil, want original value") + } + + clearReq := httptest.NewRequest(http.MethodPatch, "/api/v1/scheduled-tasks/"+created.ID, strings.NewReader(`{"expires_at":null}`)) + clearRec := httptest.NewRecorder() + h.Routes().ServeHTTP(clearRec, clearReq) + if clearRec.Code != http.StatusOK { + t.Fatalf("clear patch status = %d, want %d: %s", clearRec.Code, http.StatusOK, clearRec.Body.String()) + } + var cleared apitypes.ScheduledTask + if err := json.NewDecoder(clearRec.Body).Decode(&cleared); err != nil { + t.Fatalf("decode clear response: %v", err) + } + if cleared.ExpiresAt != nil { + t.Fatalf("cleared ExpiresAt = %v, want nil", cleared.ExpiresAt) + } +} diff --git a/internal/apitypes/team.go b/internal/apitypes/team.go index 36429b6d..560ae152 100644 --- a/internal/apitypes/team.go +++ b/internal/apitypes/team.go @@ -210,6 +210,49 @@ type PatchAgentTaskRequest struct { Reason string `json:"reason,omitempty"` } +type ScheduledTask struct { + ID string `json:"id"` + Title string `json:"title"` + AgentID string `json:"agent_id"` + Prompt string `json:"prompt"` + Recurrence string `json:"recurrence"` + Enabled bool `json:"enabled"` + NextRunAt time.Time `json:"next_run_at"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ScheduledTaskRun struct { + ID string `json:"id"` + ScheduledTaskID string `json:"scheduled_task_id"` + TriggeredAt time.Time `json:"triggered_at"` + Status string `json:"status"` + TaskID string `json:"task_id,omitempty"` + Error string `json:"error,omitempty"` +} + +type CreateScheduledTaskRequest struct { + Title string `json:"title"` + AgentID string `json:"agent_id"` + Prompt string `json:"prompt"` + Recurrence string `json:"recurrence"` + FirstRunAt time.Time `json:"first_run_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +type PatchScheduledTaskRequest struct { + Title *string `json:"title,omitempty"` + AgentID *string `json:"agent_id,omitempty"` + Prompt *string `json:"prompt,omitempty"` + Recurrence *string `json:"recurrence,omitempty"` + NextRunAt *time.Time `json:"next_run_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + type CreateTeamApprovalRequest struct { TaskID string `json:"task_id,omitempty"` RequestedBy string `json:"requested_by"` diff --git a/internal/config/config.go b/internal/config/config.go index 0f541e11..f1f717f5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -410,6 +410,10 @@ func DefaultTasksDir() (string, error) { return DefaultDomainDir("tasks") } +func DefaultScheduledTasksDir() (string, error) { + return DefaultDomainDir("scheduled-tasks") +} + func DefaultHubRegistryPath() string { dir, err := DefaultDomainDir(HubDirName) if err != nil { diff --git a/internal/scheduledtask/model.go b/internal/scheduledtask/model.go new file mode 100644 index 00000000..1ea06b60 --- /dev/null +++ b/internal/scheduledtask/model.go @@ -0,0 +1,64 @@ +package scheduledtask + +import "time" + +const ( + RecurrenceOnce = "once" + RecurrenceDaily = "daily" + RecurrenceWeekly = "weekly" + RecurrenceMonthly = "monthly" + + StatusTriggered = "triggered" + StatusFailed = "failed" +) + +type Task struct { + ID string `json:"id"` + Title string `json:"title"` + AgentID string `json:"agent_id"` + Prompt string `json:"prompt"` + Recurrence string `json:"recurrence"` + Enabled bool `json:"enabled"` + NextRunAt time.Time `json:"next_run_at"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Run struct { + ID string `json:"id"` + ScheduledTaskID string `json:"scheduled_task_id"` + TriggeredAt time.Time `json:"triggered_at"` + Status string `json:"status"` + TaskID string `json:"task_id,omitempty"` + Error string `json:"error,omitempty"` +} + +type CreateInput struct { + Title string + AgentID string + Prompt string + Recurrence string + FirstRunAt time.Time + ExpiresAt *time.Time + Enabled bool +} + +type UpdateInput struct { + ID string + Title *string + AgentID *string + Prompt *string + Recurrence *string + NextRunAt *time.Time + ExpiresAt **time.Time + Enabled *bool +} + +type state struct { + NextTaskID int `json:"next_task_id"` + NextRunID int `json:"next_run_id"` + Tasks []Task `json:"tasks"` + Runs []Run `json:"runs"` +} diff --git a/internal/scheduledtask/service.go b/internal/scheduledtask/service.go new file mode 100644 index 00000000..a212a6e9 --- /dev/null +++ b/internal/scheduledtask/service.go @@ -0,0 +1,446 @@ +package scheduledtask + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "csgclaw/internal/agent" + "csgclaw/internal/agenttask" + "csgclaw/internal/taskcore" +) + +const schedulerCreatedBy = "scheduler" + +var ErrActiveTask = errors.New("scheduled task already has an active generated task") + +type agentTaskRunner interface { + CreateAgentTask(context.Context, agenttask.CreateInput) (taskcore.Task, error) + List() []taskcore.Task +} + +type taskStore interface { + Load() (state, error) + Save(state) error +} + +type Service struct { + mu sync.Mutex + store taskStore + agentTask agentTaskRunner + now func() time.Time + state state + inFlight map[string]struct{} +} + +func NewService(store taskStore, agentTask *agenttask.Service, opts ...Option) (*Service, error) { + if store == nil { + return nil, fmt.Errorf("scheduled task store is required") + } + current, err := store.Load() + if err != nil { + return nil, err + } + s := &Service{ + store: store, + agentTask: agentTask, + now: time.Now, + state: current, + inFlight: make(map[string]struct{}), + } + for _, opt := range opts { + opt(s) + } + return s, nil +} + +type Option func(*Service) + +func WithNowFunc(now func() time.Time) Option { + return func(s *Service) { + if now != nil { + s.now = now + } + } +} + +func WithAgentTaskRunner(runner agentTaskRunner) Option { + return func(s *Service) { + s.agentTask = runner + } +} + +func (s *Service) List() []Task { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + out := append([]Task(nil), s.state.Tasks...) + sort.SliceStable(out, func(i, j int) bool { + if out[i].NextRunAt.Equal(out[j].NextRunAt) { + return out[i].CreatedAt.Before(out[j].CreatedAt) + } + return out[i].NextRunAt.Before(out[j].NextRunAt) + }) + return out +} + +func (s *Service) Runs(taskID string) []Run { + taskID = strings.TrimSpace(taskID) + if s == nil || taskID == "" { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + out := make([]Run, 0) + for _, run := range s.state.Runs { + if run.ScheduledTaskID == taskID { + out = append(out, run) + } + } + sort.SliceStable(out, func(i, j int) bool { + return out[i].TriggeredAt.After(out[j].TriggeredAt) + }) + return out +} + +func (s *Service) Create(input CreateInput) (Task, error) { + if s == nil { + return Task{}, fmt.Errorf("scheduled task service is required") + } + title := strings.TrimSpace(input.Title) + agentID := agent.CanonicalID(input.AgentID) + prompt := strings.TrimSpace(input.Prompt) + recurrence := normalizeRecurrence(input.Recurrence) + if title == "" { + return Task{}, fmt.Errorf("title is required") + } + if agentID == "" { + return Task{}, fmt.Errorf("agent_id is required") + } + if prompt == "" { + return Task{}, fmt.Errorf("prompt is required") + } + if input.FirstRunAt.IsZero() { + return Task{}, fmt.Errorf("first_run_at is required") + } + now := s.now() + item := Task{ + Title: title, + AgentID: agentID, + Prompt: prompt, + Recurrence: recurrence, + Enabled: input.Enabled, + NextRunAt: input.FirstRunAt, + ExpiresAt: cloneTime(input.ExpiresAt), + CreatedAt: now, + UpdatedAt: now, + } + s.mu.Lock() + defer s.mu.Unlock() + item.ID = fmt.Sprintf("scheduled-task-%d", s.state.NextTaskID) + s.state.NextTaskID++ + s.state.Tasks = append(s.state.Tasks, item) + if err := s.store.Save(s.state); err != nil { + return Task{}, err + } + return item, nil +} + +func (s *Service) Update(input UpdateInput) (Task, error) { + if s == nil { + return Task{}, fmt.Errorf("scheduled task service is required") + } + s.mu.Lock() + defer s.mu.Unlock() + idx := s.indexLocked(input.ID) + if idx < 0 { + return Task{}, fmt.Errorf("scheduled task not found") + } + item := s.state.Tasks[idx] + if input.Title != nil { + item.Title = strings.TrimSpace(*input.Title) + } + if input.AgentID != nil { + item.AgentID = agent.CanonicalID(*input.AgentID) + } + if input.Prompt != nil { + item.Prompt = strings.TrimSpace(*input.Prompt) + } + if input.Recurrence != nil { + item.Recurrence = normalizeRecurrence(*input.Recurrence) + } + if input.NextRunAt != nil { + item.NextRunAt = *input.NextRunAt + } + if input.ExpiresAt != nil { + item.ExpiresAt = cloneTime(*input.ExpiresAt) + } + if input.Enabled != nil { + item.Enabled = *input.Enabled + } + if item.Title == "" { + return Task{}, fmt.Errorf("title is required") + } + if item.AgentID == "" { + return Task{}, fmt.Errorf("agent_id is required") + } + if item.Prompt == "" { + return Task{}, fmt.Errorf("prompt is required") + } + if item.Enabled && item.NextRunAt.IsZero() { + return Task{}, fmt.Errorf("next_run_at is required") + } + item.UpdatedAt = s.now() + s.state.Tasks[idx] = item + if err := s.store.Save(s.state); err != nil { + return Task{}, err + } + return item, nil +} + +func (s *Service) Delete(id string) error { + if s == nil { + return fmt.Errorf("scheduled task service is required") + } + s.mu.Lock() + defer s.mu.Unlock() + idx := s.indexLocked(id) + if idx < 0 { + return fmt.Errorf("scheduled task not found") + } + s.state.Tasks = append(s.state.Tasks[:idx], s.state.Tasks[idx+1:]...) + if err := s.store.Save(s.state); err != nil { + return err + } + return nil +} + +func (s *Service) RunNow(ctx context.Context, id string) (Run, error) { + if s == nil { + return Run{}, fmt.Errorf("scheduled task service is required") + } + s.mu.Lock() + idx := s.indexLocked(id) + if idx < 0 { + s.mu.Unlock() + return Run{}, fmt.Errorf("scheduled task not found") + } + item := s.state.Tasks[idx] + advanceSchedule := item.Enabled && !item.NextRunAt.IsZero() && !item.NextRunAt.After(s.now()) && !expired(item, s.now()) + s.mu.Unlock() + return s.trigger(ctx, item, advanceSchedule, false) +} + +func (s *Service) TriggerDue(ctx context.Context) []Run { + now := s.now() + s.mu.Lock() + due := make([]Task, 0) + for _, item := range s.state.Tasks { + if item.Enabled && !item.NextRunAt.IsZero() && !item.NextRunAt.After(now) && !expired(item, now) && !s.hasActiveRunLocked(item.ID) && !s.inFlightLocked(item.ID) { + due = append(due, item) + } + } + s.mu.Unlock() + runs := make([]Run, 0, len(due)) + for _, item := range due { + run, err := s.trigger(ctx, item, true, true) + if err == nil { + runs = append(runs, run) + } + } + return runs +} + +func (s *Service) Start(ctx context.Context) { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + s.TriggerDue(ctx) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.TriggerDue(ctx) + } + } +} + +func (s *Service) trigger(ctx context.Context, item Task, advanceSchedule bool, skipIfActive bool) (Run, error) { + now := s.now() + s.mu.Lock() + if s.inFlightLocked(item.ID) || s.hasActiveRunLocked(item.ID) { + s.mu.Unlock() + return Run{}, ErrActiveTask + } + run := Run{ + ID: fmt.Sprintf("scheduled-run-%d", s.state.NextRunID), + ScheduledTaskID: item.ID, + TriggeredAt: now, + Status: StatusTriggered, + TaskID: taskIDForRunID(fmt.Sprintf("scheduled-run-%d", s.state.NextRunID)), + } + s.state.NextRunID++ + s.state.Runs = append(s.state.Runs, run) + if err := s.store.Save(s.state); err != nil { + s.mu.Unlock() + return Run{}, err + } + s.inFlight[item.ID] = struct{}{} + s.mu.Unlock() + defer func() { + s.mu.Lock() + delete(s.inFlight, item.ID) + s.mu.Unlock() + }() + + if s.agentTask == nil { + run.Status = StatusFailed + run.Error = "agent task service is not configured" + } else { + task, err := s.agentTask.CreateAgentTask(ctx, agenttask.CreateInput{ + ID: run.TaskID, + AgentID: item.AgentID, + Title: item.Title, + Body: item.Prompt, + CreatedBy: schedulerCreatedBy, + }) + if err != nil { + run.Status = StatusFailed + run.Error = err.Error() + } else { + run.TaskID = task.ID + } + } + s.mu.Lock() + defer s.mu.Unlock() + if skipIfActive && run.Status == StatusFailed && s.hasActiveRunLocked(item.ID) { + return Run{}, ErrActiveTask + } + if idx := s.indexLocked(item.ID); idx >= 0 { + updated := s.state.Tasks[idx] + updated.LastRunAt = &now + updated.UpdatedAt = now + if advanceSchedule { + next, ok := nextRunAt(updated, now) + updated.NextRunAt = next + updated.Enabled = ok && !expired(updated, next) + } + s.state.Tasks[idx] = updated + } + for idx := len(s.state.Runs) - 1; idx >= 0; idx-- { + if s.state.Runs[idx].ID == run.ID { + s.state.Runs[idx] = run + break + } + } + err := s.store.Save(s.state) + return run, err +} + +func (s *Service) inFlightLocked(id string) bool { + if s == nil || s.inFlight == nil { + return false + } + _, ok := s.inFlight[strings.TrimSpace(id)] + return ok +} + +func (s *Service) hasActiveRunLocked(id string) bool { + if s == nil || s.agentTask == nil { + return false + } + taskID := "" + for i := len(s.state.Runs) - 1; i >= 0; i-- { + run := s.state.Runs[i] + if run.ScheduledTaskID == id && strings.TrimSpace(run.TaskID) != "" { + taskID = strings.TrimSpace(run.TaskID) + break + } + } + if taskID == "" { + return false + } + for _, task := range s.agentTask.List() { + if task.ID == taskID { + return !isTerminalTaskStatus(task.Status) + } + } + return false +} + +func isTerminalTaskStatus(status string) bool { + switch strings.TrimSpace(status) { + case taskcore.StatusCompleted, taskcore.StatusFailed, taskcore.StatusCancelled: + return true + default: + return false + } +} + +func (s *Service) indexLocked(id string) int { + id = strings.TrimSpace(id) + for i, item := range s.state.Tasks { + if item.ID == id { + return i + } + } + return -1 +} + +func normalizeRecurrence(value string) string { + switch strings.TrimSpace(value) { + case RecurrenceDaily, RecurrenceWeekly, RecurrenceMonthly: + return strings.TrimSpace(value) + default: + return RecurrenceOnce + } +} + +func nextRunAt(item Task, from time.Time) (time.Time, bool) { + switch normalizeRecurrence(item.Recurrence) { + case RecurrenceDaily: + return advanceUntilAfter(item.NextRunAt, from, func(t time.Time) time.Time { return t.AddDate(0, 0, 1) }), true + case RecurrenceWeekly: + return advanceUntilAfter(item.NextRunAt, from, func(t time.Time) time.Time { return t.AddDate(0, 0, 7) }), true + case RecurrenceMonthly: + return advanceUntilAfter(item.NextRunAt, from, func(t time.Time) time.Time { return t.AddDate(0, 1, 0) }), true + default: + return time.Time{}, false + } +} + +func advanceUntilAfter(next, from time.Time, advance func(time.Time) time.Time) time.Time { + if next.IsZero() { + next = from + } + for !next.After(from) { + next = advance(next) + } + return next +} + +func expired(item Task, at time.Time) bool { + return item.ExpiresAt != nil && at.After(*item.ExpiresAt) +} + +func cloneTime(value *time.Time) *time.Time { + if value == nil { + return nil + } + next := *value + return &next +} + +func taskIDForRunID(runID string) string { + runID = strings.TrimSpace(runID) + if runID == "" { + return "" + } + return "task-" + runID +} diff --git a/internal/scheduledtask/service_test.go b/internal/scheduledtask/service_test.go new file mode 100644 index 00000000..f462354d --- /dev/null +++ b/internal/scheduledtask/service_test.go @@ -0,0 +1,275 @@ +package scheduledtask + +import ( + "context" + "testing" + "time" + + "csgclaw/internal/agenttask" + "csgclaw/internal/taskcore" +) + +func TestCreateAndTriggerDueAdvancesSchedule(t *testing.T) { + now := time.Date(2026, 7, 7, 10, 40, 0, 0, time.Local) + svc := newTestService(t, now) + item, err := svc.Create(CreateInput{ + Title: "Daily check", + AgentID: "u-worker", + Prompt: "Summarize open work.", + Recurrence: RecurrenceDaily, + FirstRunAt: now.Add(-time.Minute), + Enabled: true, + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + runs := svc.TriggerDue(context.Background()) + if len(runs) != 1 { + t.Fatalf("TriggerDue() runs len = %d, want 1", len(runs)) + } + if runs[0].Status != StatusFailed || runs[0].ScheduledTaskID != item.ID { + t.Fatalf("TriggerDue() run = %+v, want failed run for scheduled task", runs[0]) + } + got := svc.List()[0] + if !got.Enabled { + t.Fatalf("scheduled task enabled = false, want true") + } + if !got.NextRunAt.After(now) { + t.Fatalf("NextRunAt = %s, want after %s", got.NextRunAt, now) + } + if got.LastRunAt == nil || !got.LastRunAt.Equal(now) { + t.Fatalf("LastRunAt = %v, want %s", got.LastRunAt, now) + } +} + +func TestRunNowDoesNotAdvanceSchedule(t *testing.T) { + now := time.Date(2026, 7, 7, 10, 40, 0, 0, time.Local) + svc := newTestService(t, now) + nextRunAt := now.Add(time.Hour) + item, err := svc.Create(CreateInput{ + Title: "Weekly check", + AgentID: "worker", + Prompt: "Report status.", + Recurrence: RecurrenceWeekly, + FirstRunAt: nextRunAt, + Enabled: true, + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if _, err := svc.RunNow(context.Background(), item.ID); err != nil { + t.Fatalf("RunNow() error = %v", err) + } + got := svc.List()[0] + if !got.NextRunAt.Equal(nextRunAt) { + t.Fatalf("NextRunAt = %s, want unchanged %s", got.NextRunAt, nextRunAt) + } + if runs := svc.Runs(item.ID); len(runs) != 1 { + t.Fatalf("Runs() len = %d, want 1", len(runs)) + } +} + +func TestUpdateAllowsDisabledCompletedOnceTask(t *testing.T) { + now := time.Date(2026, 7, 7, 10, 40, 0, 0, time.Local) + svc := newTestService(t, now) + item, err := svc.Create(CreateInput{ + Title: "One shot", + AgentID: "worker", + Prompt: "Report once.", + Recurrence: RecurrenceOnce, + FirstRunAt: now.Add(-time.Minute), + Enabled: true, + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + svc.TriggerDue(context.Background()) + + disabled := false + if _, err := svc.Update(UpdateInput{ID: item.ID, Enabled: &disabled}); err != nil { + t.Fatalf("Update(disabled completed once) error = %v", err) + } +} + +func TestRunNowDueOnceTaskAdvancesScheduleAndPreventsAutoDuplicate(t *testing.T) { + now := time.Date(2026, 7, 7, 10, 40, 0, 0, time.Local) + runner := &fakeAgentTaskRunner{} + svc := newTestServiceWithRunner(t, now, runner) + item, err := svc.Create(CreateInput{ + Title: "One shot", + AgentID: "worker", + Prompt: "Report once.", + Recurrence: RecurrenceOnce, + FirstRunAt: now.Add(-time.Minute), + Enabled: true, + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if _, err := svc.RunNow(context.Background(), item.ID); err != nil { + t.Fatalf("RunNow() error = %v", err) + } + got := svc.List()[0] + if got.Enabled { + t.Fatalf("scheduled task enabled = true, want false") + } + if !got.NextRunAt.IsZero() { + t.Fatalf("NextRunAt = %s, want zero", got.NextRunAt) + } + if runs := svc.TriggerDue(context.Background()); len(runs) != 0 { + t.Fatalf("TriggerDue() runs len = %d, want 0", len(runs)) + } + if len(runner.tasks) != 1 { + t.Fatalf("created tasks len = %d, want 1", len(runner.tasks)) + } +} + +func TestRunNowRejectsWhenGeneratedTaskIsActive(t *testing.T) { + now := time.Date(2026, 7, 7, 10, 40, 0, 0, time.Local) + runner := &fakeAgentTaskRunner{} + svc := newTestServiceWithRunner(t, now, runner) + item, err := svc.Create(CreateInput{ + Title: "Manual check", + AgentID: "worker", + Prompt: "Report status.", + Recurrence: RecurrenceDaily, + FirstRunAt: now.Add(time.Hour), + Enabled: true, + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if _, err := svc.RunNow(context.Background(), item.ID); err != nil { + t.Fatalf("RunNow(first) error = %v", err) + } + if _, err := svc.RunNow(context.Background(), item.ID); err != ErrActiveTask { + t.Fatalf("RunNow(second) error = %v, want ErrActiveTask", err) + } +} + +func TestTriggerDueDoesNotDuplicateWhenFinalSaveFailsAfterTaskCreate(t *testing.T) { + now := time.Date(2026, 7, 7, 10, 40, 0, 0, time.Local) + store := &flakyStore{failOnSave: 3} + runner := &fakeAgentTaskRunner{} + svc, err := NewService(store, nil, WithNowFunc(func() time.Time { return now }), WithAgentTaskRunner(runner)) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + item, err := svc.Create(CreateInput{ + Title: "Daily check", + AgentID: "worker", + Prompt: "Report status.", + Recurrence: RecurrenceDaily, + FirstRunAt: now.Add(-time.Minute), + Enabled: true, + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + if runs := svc.TriggerDue(context.Background()); len(runs) != 0 { + t.Fatalf("TriggerDue() runs len = %d, want 0 when final save fails", len(runs)) + } + if len(runner.tasks) != 1 { + t.Fatalf("created tasks len = %d, want 1", len(runner.tasks)) + } + if storedRuns := store.state.Runs; len(storedRuns) != 1 || storedRuns[0].TaskID != runner.tasks[0].ID { + t.Fatalf("stored runs = %+v, want pre-saved run with generated task id %q", storedRuns, runner.tasks[0].ID) + } + + restarted, err := NewService(store, nil, WithNowFunc(func() time.Time { return now }), WithAgentTaskRunner(runner)) + if err != nil { + t.Fatalf("NewService(restarted) error = %v", err) + } + if runs := restarted.TriggerDue(context.Background()); len(runs) != 0 { + t.Fatalf("TriggerDue(restarted) runs len = %d, want 0 while generated task is active", len(runs)) + } + if len(runner.tasks) != 1 { + t.Fatalf("created tasks after restart len = %d, want 1", len(runner.tasks)) + } + if stored := restarted.Runs(item.ID); len(stored) != 1 || stored[0].TaskID != runner.tasks[0].ID { + t.Fatalf("Runs() = %+v, want original generated task id %q", stored, runner.tasks[0].ID) + } +} + +func newTestService(t *testing.T, now time.Time) *Service { + return newTestServiceWithRunner(t, now, nil) +} + +func newTestServiceWithRunner(t *testing.T, now time.Time, runner agentTaskRunner) *Service { + t.Helper() + store, err := NewStore(t.TempDir()) + if err != nil { + t.Fatalf("NewStore() error = %v", err) + } + svc, err := NewService(store, nil, WithNowFunc(func() time.Time { return now }), WithAgentTaskRunner(runner)) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + return svc +} + +type fakeAgentTaskRunner struct { + tasks []taskcore.Task +} + +func (r *fakeAgentTaskRunner) CreateAgentTask(_ context.Context, input agenttask.CreateInput) (taskcore.Task, error) { + id := input.ID + if id == "" { + id = "task-" + string(rune('1'+len(r.tasks))) + } + task := taskcore.Task{ + ID: id, + AssignmentType: taskcore.AssignmentTypeAgent, + AssignmentID: input.AgentID, + Title: input.Title, + Body: input.Body, + Status: taskcore.StatusAssigned, + CreatedBy: input.CreatedBy, + } + r.tasks = append(r.tasks, task) + return task, nil +} + +func (r *fakeAgentTaskRunner) List() []taskcore.Task { + return append([]taskcore.Task(nil), r.tasks...) +} + +type flakyStore struct { + state state + saveCount int + failOnSave int +} + +func (s *flakyStore) Load() (state, error) { + if s.state.NextTaskID == 0 { + s.state.NextTaskID = 1 + } + if s.state.NextRunID == 0 { + s.state.NextRunID = 1 + } + return s.state, nil +} + +func (s *flakyStore) Save(next state) error { + s.saveCount++ + if s.failOnSave > 0 && s.saveCount == s.failOnSave { + return errFlakyStoreSave + } + s.state = cloneState(next) + return nil +} + +var errFlakyStoreSave = errTestStoreSave{} + +type errTestStoreSave struct{} + +func (errTestStoreSave) Error() string { return "test store save failed" } + +func cloneState(input state) state { + out := input + out.Tasks = append([]Task(nil), input.Tasks...) + out.Runs = append([]Run(nil), input.Runs...) + return out +} diff --git a/internal/scheduledtask/store.go b/internal/scheduledtask/store.go new file mode 100644 index 00000000..810c8677 --- /dev/null +++ b/internal/scheduledtask/store.go @@ -0,0 +1,87 @@ +package scheduledtask + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" +) + +const stateFileName = "state.json" + +type Store struct { + mu sync.Mutex + path string +} + +func NewStore(root string) (*Store, error) { + root = strings.TrimSpace(root) + if root == "" { + return nil, fmt.Errorf("scheduled task store root is required") + } + return &Store{path: filepath.Join(root, stateFileName)}, nil +} + +func (s *Store) Load() (state, error) { + if s == nil { + return state{}, fmt.Errorf("scheduled task store is required") + } + s.mu.Lock() + defer s.mu.Unlock() + return s.loadLocked() +} + +func (s *Store) Save(next state) error { + if s == nil { + return fmt.Errorf("scheduled task store is required") + } + s.mu.Lock() + defer s.mu.Unlock() + return s.saveLocked(next) +} + +func (s *Store) loadLocked() (state, error) { + var current state + data, err := os.ReadFile(s.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + current.NextTaskID = 1 + current.NextRunID = 1 + return current, nil + } + return state{}, err + } + if len(data) == 0 { + current.NextTaskID = 1 + current.NextRunID = 1 + return current, nil + } + if err := json.Unmarshal(data, ¤t); err != nil { + return state{}, err + } + if current.NextTaskID <= 0 { + current.NextTaskID = 1 + } + if current.NextRunID <= 0 { + current.NextRunID = 1 + } + return current, nil +} + +func (s *Store) saveLocked(next state) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(next, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, append(data, '\n'), 0o644); err != nil { + return err + } + return os.Rename(tmp, s.path) +} diff --git a/internal/server/http.go b/internal/server/http.go index e43357b6..e2134724 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -16,6 +16,7 @@ import ( "csgclaw/internal/im" "csgclaw/internal/llm" "csgclaw/internal/participant" + "csgclaw/internal/scheduledtask" "csgclaw/internal/team" hub "csgclaw/internal/template" "csgclaw/internal/upgrade" @@ -33,6 +34,7 @@ type Options struct { LLM *llm.Service Team *team.Service AgentTask *agenttask.Service + ScheduledTask *scheduledtask.Service TeamAdapters *team.AdapterRegistry Upgrade *upgrade.Manager ActivityDecider api.ActivityDecider @@ -49,6 +51,7 @@ func newHandler(opts Options) *api.Handler { handler.SetHubService(opts.Hub) handler.SetTeamService(opts.Team) handler.SetAgentTaskService(opts.AgentTask) + handler.SetScheduledTaskService(opts.ScheduledTask) if opts.TeamAdapters != nil { handler.SetTeamAdapterRegistry(opts.TeamAdapters) } @@ -95,6 +98,9 @@ func Run(opts Options) error { if opts.Upgrade != nil { go opts.Upgrade.Start(opts.Context) } + if opts.ScheduledTask != nil { + go opts.ScheduledTask.Start(opts.Context) + } errCh := make(chan error, 1) go func() { diff --git a/web/app/src/api/scheduledTasks.ts b/web/app/src/api/scheduledTasks.ts new file mode 100644 index 00000000..a58d378a --- /dev/null +++ b/web/app/src/api/scheduledTasks.ts @@ -0,0 +1,89 @@ +import { del, get, patch, post } from "@/api/client"; +import { + normalizeScheduledTask, + normalizeScheduledTaskList, + normalizeScheduledTaskRun, + normalizeScheduledTaskRunList, +} from "@/models/scheduledTasks"; +import type { ScheduledTaskRecurrence, WorkspaceScheduledTask, WorkspaceScheduledTaskRun } from "@/models/scheduledTasks"; + +export type CreateScheduledTaskPayload = { + title: string; + agent_id: string; + prompt: string; + recurrence: ScheduledTaskRecurrence; + first_run_at: string; + expires_at?: string; + enabled?: boolean; +}; + +export type UpdateScheduledTaskPayload = Partial<{ + title: string; + agent_id: string; + prompt: string; + recurrence: ScheduledTaskRecurrence; + next_run_at: string; + expires_at: string; + enabled: boolean; +}>; + +export async function fetchScheduledTasks(): Promise { + return normalizeScheduledTaskList(await get("/api/v1/scheduled-tasks")); +} + +export async function fetchScheduledTaskRuns(taskID: string): Promise { + const normalizedTaskID = String(taskID || "").trim(); + if (!normalizedTaskID) { + return []; + } + return normalizeScheduledTaskRunList( + await get(`/api/v1/scheduled-tasks/${encodeURIComponent(normalizedTaskID)}/runs`), + ); +} + +export async function createScheduledTask(payload: CreateScheduledTaskPayload): Promise { + const task = normalizeScheduledTask(await post("/api/v1/scheduled-tasks", payload)); + if (!task) { + throw new Error("Invalid scheduled task response"); + } + return task; +} + +export async function updateScheduledTask( + taskID: string, + payload: UpdateScheduledTaskPayload, +): Promise { + const normalizedTaskID = String(taskID || "").trim(); + if (!normalizedTaskID) { + throw new Error("Scheduled task ID is required"); + } + const task = normalizeScheduledTask( + await patch(`/api/v1/scheduled-tasks/${encodeURIComponent(normalizedTaskID)}`, payload), + ); + if (!task) { + throw new Error("Invalid scheduled task response"); + } + return task; +} + +export async function deleteScheduledTask(taskID: string): Promise { + const normalizedTaskID = String(taskID || "").trim(); + if (!normalizedTaskID) { + throw new Error("Scheduled task ID is required"); + } + await del(`/api/v1/scheduled-tasks/${encodeURIComponent(normalizedTaskID)}`); +} + +export async function runScheduledTaskNow(taskID: string): Promise { + const normalizedTaskID = String(taskID || "").trim(); + if (!normalizedTaskID) { + throw new Error("Scheduled task ID is required"); + } + const run = normalizeScheduledTaskRun( + await post(`/api/v1/scheduled-tasks/${encodeURIComponent(normalizedTaskID)}/run-now`, {}), + ); + if (!run) { + throw new Error("Invalid scheduled task run response"); + } + return run; +} diff --git a/web/app/src/hooks/workspace/useTaskController.ts b/web/app/src/hooks/workspace/useTaskController.ts index e58162a7..e1b20933 100644 --- a/web/app/src/hooks/workspace/useTaskController.ts +++ b/web/app/src/hooks/workspace/useTaskController.ts @@ -8,6 +8,14 @@ import { planWorkspaceTask, startWorkspaceTask, } from "@/api/tasks"; +import { + createScheduledTask, + fetchScheduledTaskRuns, + fetchScheduledTasks, + runScheduledTaskNow, + updateScheduledTask, + type CreateScheduledTaskPayload, +} from "@/api/scheduledTasks"; import { WorkspacePaneTypes } from "@/models/routing"; import { ApiError, errorMessage } from "@/api/client"; import { rootTaskForTask, type WorkspaceTask, rootTasks, shouldPollTransitionalTasks } from "@/models/tasks"; @@ -17,10 +25,12 @@ import type { TranslateFn } from "@/models/conversations"; import type { NavigatePaneOptions } from "./types"; const TASKS_QUERY_KEY = ["workspace", "tasks"] as const; +const SCHEDULED_TASKS_QUERY_KEY = ["workspace", "scheduled-tasks"] as const; const TASK_BOARD_POLL_DELAY_MS = 3000; const TASK_BOARD_POLL_STATUSES = new Set(["pending", "assigned", "in_progress"]); const TASK_TAB_REVALIDATE_STALE_MS = 5000; const teamEventsQueryKey = (teamID: string) => ["workspace", "team-events", teamID] as const; +const scheduledTaskRunsQueryKey = (taskID: string) => ["workspace", "scheduled-task-runs", taskID] as const; type UseTaskControllerArgs = { activePane: { type?: string; id?: string }; @@ -39,8 +49,14 @@ export function useTaskController({ }: UseTaskControllerArgs) { const queryClient = useQueryClient(); const [showCreateTaskModal, setShowCreateTaskModal] = useState(false); + const [showCreateScheduledTaskModal, setShowCreateScheduledTaskModal] = useState(false); const [createTaskBusy, setCreateTaskBusy] = useState(false); const [createTaskError, setCreateTaskError] = useState(""); + const [createScheduledTaskBusy, setCreateScheduledTaskBusy] = useState(false); + const [createScheduledTaskError, setCreateScheduledTaskError] = useState(""); + const [scheduledTaskActionID, setScheduledTaskActionID] = useState(""); + const [scheduledTaskActionError, setScheduledTaskActionError] = useState(""); + const [selectedScheduledTaskID, setSelectedScheduledTaskID] = useState(""); const [planTaskBusy, setPlanTaskBusy] = useState(false); const [startTaskBusy, setStartTaskBusy] = useState(false); const [planningTaskID, setPlanningTaskID] = useState(""); @@ -52,6 +68,10 @@ export function useTaskController({ queryKey: TASKS_QUERY_KEY, queryFn: fetchGlobalTasks, }); + const scheduledTasksQuery = useQuery({ + queryKey: SCHEDULED_TASKS_QUERY_KEY, + queryFn: fetchScheduledTasks, + }); const { dataUpdatedAt: tasksDataUpdatedAt, isFetching: tasksFetching, refetch: refetchTasks } = tasksQuery; const teamsQuery = useQuery({ queryKey: workspaceQueryKeys.teams(), @@ -59,6 +79,14 @@ export function useTaskController({ }); const tasks = useMemo(() => tasksQuery.data ?? [], [tasksQuery.data]); + const scheduledTasks = useMemo(() => scheduledTasksQuery.data ?? [], [scheduledTasksQuery.data]); + const visibleScheduledTaskID = selectedScheduledTaskID || scheduledTasks[0]?.id || ""; + const scheduledTaskRunsQuery = useQuery({ + queryKey: scheduledTaskRunsQueryKey(visibleScheduledTaskID), + queryFn: () => fetchScheduledTaskRuns(visibleScheduledTaskID), + enabled: Boolean(visibleScheduledTaskID), + }); + const scheduledTaskRuns = useMemo(() => scheduledTaskRunsQuery.data ?? [], [scheduledTaskRunsQuery.data]); const teams = useMemo(() => teamsQuery.data ?? [], [teamsQuery.data]); const parentTasks = useMemo(() => rootTasks(tasks), [tasks]); const selectedTaskID = activePane.type === WorkspacePaneTypes.task ? String(activePane.id || "") : ""; @@ -189,6 +217,61 @@ export function useTaskController({ } } + async function createScheduledTaskDraft(payload: CreateScheduledTaskPayload): Promise { + if (createScheduledTaskBusy) { + return; + } + setCreateScheduledTaskBusy(true); + setCreateScheduledTaskError(""); + try { + const created = await createScheduledTask(payload); + setShowCreateScheduledTaskModal(false); + setSelectedScheduledTaskID(created.id); + await scheduledTasksQuery.refetch(); + } catch (err) { + setCreateScheduledTaskError(errorMessage(err, t("scheduledTaskCreateFailed"))); + } finally { + setCreateScheduledTaskBusy(false); + } + } + + async function toggleScheduledTask(taskID: string, enabled: boolean): Promise { + if (!taskID) { + return; + } + setScheduledTaskActionID(taskID); + setScheduledTaskActionError(""); + try { + await updateScheduledTask(taskID, { enabled }); + await scheduledTasksQuery.refetch(); + } catch (err) { + setScheduledTaskActionError(errorMessage(err, t("scheduledTaskUpdateFailed"))); + } finally { + setScheduledTaskActionID(""); + } + } + + async function runScheduledTask(taskID: string): Promise { + if (!taskID) { + return; + } + setScheduledTaskActionID(taskID); + setScheduledTaskActionError(""); + try { + const run = await runScheduledTaskNow(taskID); + await scheduledTasksQuery.refetch(); + await queryClient.invalidateQueries({ queryKey: scheduledTaskRunsQueryKey(taskID) }); + await tasksQuery.refetch(); + if (run.task_id) { + onSelectTask(run.task_id); + } + } catch (err) { + setScheduledTaskActionError(errorMessage(err, t("scheduledTaskRunFailed"))); + } finally { + setScheduledTaskActionID(""); + } + } + async function autoPlanAndStartTask(taskID: string, teamID: string): Promise { if (teamID && taskID) { try { @@ -317,34 +400,57 @@ export function useTaskController({ setCreateTaskError(""); setShowCreateTaskModal(true); }, + openCreateScheduledTaskModal: () => { + setCreateScheduledTaskError(""); + setShowCreateScheduledTaskModal(true); + }, taskViewProps: { t, agents, tasks, taskEvents, teams, + scheduledTasks, + scheduledTaskRuns, + selectedScheduledTaskID: visibleScheduledTaskID, selectedTask, selectedTaskID, - loading: tasksQuery.isLoading, + loading: tasksQuery.isLoading || scheduledTasksQuery.isLoading, error: (tasksQuery.isError ? errorMessage(tasksQuery.error, t("tasksLoadFailed")) : "") || + (scheduledTasksQuery.isError ? errorMessage(scheduledTasksQuery.error, t("scheduledTasksLoadFailed")) : "") || (teamsQuery.isError ? errorMessage(teamsQuery.error, t("teamsLoadFailed")) : ""), createTaskBusy, createTaskError, + createScheduledTaskBusy, + createScheduledTaskError, planTaskBusy, planningTaskID, startTaskBusy, startingTaskID, taskActionError, + scheduledTaskActionID, + scheduledTaskActionError, showCreateTaskModal, + showCreateScheduledTaskModal, parentDetailTaskID, onCloseCreateTaskModal: () => setShowCreateTaskModal(false), + onCloseCreateScheduledTaskModal: () => setShowCreateScheduledTaskModal(false), + onOpenCreateScheduledTaskModal: () => setShowCreateScheduledTaskModal(true), onCloseParentTaskDetail: () => setParentDetailTaskID(""), onCreateTask: createTask, + onCreateScheduledTask: createScheduledTaskDraft, onPlanTask: planTask, onStartTask: startTask, + onRunScheduledTask: runScheduledTask, + onToggleScheduledTask: toggleScheduledTask, + onSelectScheduledTask: setSelectedScheduledTaskID, onRefresh: () => { void tasksQuery.refetch(); + void scheduledTasksQuery.refetch(); + if (visibleScheduledTaskID) { + void scheduledTaskRunsQuery.refetch(); + } if (activeEventsTeamID) { void taskEventsQuery.refetch(); } diff --git a/web/app/src/models/scheduledTasks.ts b/web/app/src/models/scheduledTasks.ts new file mode 100644 index 00000000..1ad42226 --- /dev/null +++ b/web/app/src/models/scheduledTasks.ts @@ -0,0 +1,124 @@ +export type ScheduledTaskRecurrence = "once" | "daily" | "weekly" | "monthly"; + +export type WorkspaceScheduledTask = { + id: string; + title: string; + agent_id: string; + prompt: string; + recurrence: ScheduledTaskRecurrence; + enabled: boolean; + next_run_at: string; + last_run_at: string; + expires_at: string; + created_at: string; + updated_at: string; +}; + +export type WorkspaceScheduledTaskRun = { + id: string; + scheduled_task_id: string; + triggered_at: string; + status: string; + task_id: string; + error: string; +}; + +export function normalizeScheduledTaskList(input: unknown): WorkspaceScheduledTask[] { + if (!Array.isArray(input)) { + return []; + } + return input + .map(normalizeScheduledTask) + .filter((item): item is WorkspaceScheduledTask => Boolean(item)) + .sort((left, right) => left.next_run_at.localeCompare(right.next_run_at) || left.created_at.localeCompare(right.created_at)); +} + +export function normalizeScheduledTaskRunList(input: unknown): WorkspaceScheduledTaskRun[] { + if (!Array.isArray(input)) { + return []; + } + return input + .map(normalizeScheduledTaskRun) + .filter((item): item is WorkspaceScheduledTaskRun => Boolean(item)) + .sort((left, right) => right.triggered_at.localeCompare(left.triggered_at)); +} + +export function normalizeScheduledTask(input: unknown): WorkspaceScheduledTask | null { + if (!input || typeof input !== "object") { + return null; + } + const item = input as Record; + const id = text(item.id); + if (!id) { + return null; + } + return { + id, + title: text(item.title) || id, + agent_id: text(item.agent_id), + prompt: text(item.prompt), + recurrence: normalizeRecurrence(item.recurrence), + enabled: booleanValue(item.enabled, true), + next_run_at: timeText(item.next_run_at), + last_run_at: timeText(item.last_run_at), + expires_at: timeText(item.expires_at), + created_at: text(item.created_at), + updated_at: text(item.updated_at), + }; +} + +export function normalizeScheduledTaskRun(input: unknown): WorkspaceScheduledTaskRun | null { + if (!input || typeof input !== "object") { + return null; + } + const item = input as Record; + const id = text(item.id); + if (!id) { + return null; + } + return { + id, + scheduled_task_id: text(item.scheduled_task_id), + triggered_at: text(item.triggered_at), + status: text(item.status), + task_id: text(item.task_id), + error: text(item.error), + }; +} + +export function scheduledTaskRecurrenceLabel(value: string, t: (key: string) => string): string { + switch (value) { + case "daily": + return t("scheduledTaskRecurrenceDaily"); + case "weekly": + return t("scheduledTaskRecurrenceWeekly"); + case "monthly": + return t("scheduledTaskRecurrenceMonthly"); + default: + return t("scheduledTaskRecurrenceOnce"); + } +} + +function normalizeRecurrence(value: unknown): ScheduledTaskRecurrence { + switch (text(value)) { + case "daily": + case "weekly": + case "monthly": + return text(value) as ScheduledTaskRecurrence; + default: + return "once"; + } +} + +function text(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function timeText(value: unknown): string { + const raw = text(value); + return raw.startsWith("0001-01-01") ? "" : raw; +} + +function booleanValue(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} diff --git a/web/app/src/pages/TasksPage/components/TasksView/TasksView.module.css b/web/app/src/pages/TasksPage/components/TasksView/TasksView.module.css index 929e74db..cff11af5 100644 --- a/web/app/src/pages/TasksPage/components/TasksView/TasksView.module.css +++ b/web/app/src/pages/TasksPage/components/TasksView/TasksView.module.css @@ -1254,6 +1254,193 @@ margin-top: 14px; } +.taskViewSwitch { + display: inline-flex; + width: fit-content; + padding: 3px; + gap: 2px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-muted); +} + +.taskViewSwitch button { + min-width: 76px; + min-height: 30px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.taskViewSwitch button[data-active] { + background: var(--surface); + color: var(--text); + box-shadow: 0 1px 2px color-mix(in srgb, var(--gray-950) 10%, transparent); +} + +.scheduledTaskLayout { + display: grid; + grid-template-columns: minmax(240px, 320px) minmax(0, 1fr); + min-height: 520px; + border-top: 1px solid var(--line); +} + +.scheduledTaskList { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + border-right: 1px solid var(--line); +} + +.scheduledTaskListHead, +.scheduledTaskDetailHead, +.scheduledTaskActions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.scheduledTaskRow { + display: flex; + min-height: 64px; + flex-direction: column; + align-items: stretch; + justify-content: center; + gap: 6px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + color: var(--text); + padding: 10px 12px; + text-align: left; + cursor: pointer; +} + +.scheduledTaskRow[data-active] { + border-color: color-mix(in srgb, var(--primary) 50%, var(--line)); + background: color-mix(in srgb, var(--primary) 8%, var(--surface)); +} + +.scheduledTaskRow span { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.scheduledTaskRow strong, +.scheduledTaskRow small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.scheduledTaskRow small { + color: var(--muted); +} + +.scheduledTaskDetail { + padding: 18px; + overflow: auto; +} + +.scheduledTaskDetailHead h2 { + margin: 0; + font-size: 18px; + line-height: 24px; +} + +.scheduledTaskDetailHead p { + margin: 4px 0 0; + color: var(--muted); + font-size: 13px; +} + +.scheduledTaskMeta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin: 18px 0; +} + +.scheduledTaskMeta div { + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; +} + +.scheduledTaskMeta dt { + color: var(--muted); + font-size: 12px; +} + +.scheduledTaskMeta dd { + margin: 4px 0 0; + overflow: hidden; + color: var(--text); + font-size: 13px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.scheduledTaskPrompt { + min-height: 92px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-muted); + color: var(--text); + white-space: pre-wrap; +} + +.scheduledTaskRuns { + margin-top: 18px; +} + +.scheduledTaskRuns h3 { + margin: 0 0 10px; + font-size: 14px; +} + +.scheduledTaskRunRow { + display: grid; + grid-template-columns: minmax(140px, 180px) minmax(80px, 100px) minmax(120px, 1fr); + gap: 10px; + align-items: center; + padding: 10px 0; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 13px; +} + +.scheduledTaskRunRow strong { + color: var(--text); +} + +.scheduledTaskRunRow button { + border: 0; + background: transparent; + color: var(--primary); + padding: 0; + text-align: left; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.scheduledTaskRunRow small { + grid-column: 1 / -1; + color: var(--danger); +} + @media (max-width: 980px) { .tasksBoardWorkbench { min-height: unset; @@ -1292,6 +1479,23 @@ grid-template-columns: 1fr; } + .scheduledTaskLayout { + grid-template-columns: 1fr; + } + + .scheduledTaskList { + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .scheduledTaskMeta { + grid-template-columns: 1fr; + } + + .scheduledTaskRunRow { + grid-template-columns: 1fr; + } + .taskDetailDialog { width: calc(100vw - 24px); max-height: calc(100vh - 24px); diff --git a/web/app/src/pages/TasksPage/components/TasksView/TasksView.tsx b/web/app/src/pages/TasksPage/components/TasksView/TasksView.tsx index 69369950..0ae1180e 100644 --- a/web/app/src/pages/TasksPage/components/TasksView/TasksView.tsx +++ b/web/app/src/pages/TasksPage/components/TasksView/TasksView.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import type { ComponentProps, ReactNode } from "react"; -import { Bot, ChevronDown, Users, X } from "lucide-react"; +import { Bot, CalendarClock, ChevronDown, Play, Users, X } from "lucide-react"; import { Button, type ButtonVariant, @@ -16,8 +16,15 @@ import { } from "@/components/ui"; import { TaskStatusPill, TaskSubtaskIndicator } from "@/components/business"; import type { CreateWorkspaceTaskPayload } from "@/api/tasks"; +import type { CreateScheduledTaskPayload } from "@/api/scheduledTasks"; import type { AgentLike } from "@/models/agents"; import type { TranslateFn } from "@/models/conversations"; +import { + scheduledTaskRecurrenceLabel, + type ScheduledTaskRecurrence, + type WorkspaceScheduledTask, + type WorkspaceScheduledTaskRun, +} from "@/models/scheduledTasks"; import { TASK_BOARD_STATUSES, displayTaskAssignedAgent, @@ -62,12 +69,40 @@ type TaskCreateFieldErrors = { title?: string; }; +type ScheduledTaskCreateDraft = { + agentID: string; + date: string; + expiresDate: string; + prompt: string; + recurrence: ScheduledTaskRecurrence; + time: string; + title: string; +}; + +type ScheduledTaskCreateFieldErrors = { + agentID?: string; + date?: string; + prompt?: string; + time?: string; + title?: string; +}; + const emptyCreateDraft: TaskCreateDraft = { assignee: "", title: "", description: "", }; +const emptyScheduledTaskDraft: ScheduledTaskCreateDraft = { + agentID: "", + date: "", + expiresDate: "", + prompt: "", + recurrence: "once", + time: "", + title: "", +}; + function truncateTaskTitle(value: string): string { const chars = Array.from(value); if (chars.length <= TASK_TITLE_MAX_LENGTH) { @@ -137,6 +172,31 @@ function taskAssignmentOptions(teams: readonly WorkspaceTeam[], agents: readonly ]; } +function scheduledTaskAgentOptions(agents: readonly AgentLike[]) { + return assignableAgents(agents).map((agent) => ({ + value: String(agent.id || ""), + label: displayAgent(agent), + description: String(agent.id || ""), + })); +} + +function todayInputValue(now = new Date()): string { + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; +} + +function timeInputValue(now = new Date()): string { + return `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; +} + +function localDateTimeISO(date: string, time: string): string { + const parsed = new Date(`${date}T${time}:00`); + return Number.isNaN(parsed.getTime()) ? "" : parsed.toISOString(); +} + +function isTerminalWorkspaceTaskStatus(status: string): boolean { + return status === "completed" || status === "failed" || status === "cancelled"; +} + type VoidOrPromise = void | Promise; export type TasksViewProps = { @@ -146,19 +206,28 @@ export type TasksViewProps = { error?: string; loading?: boolean; onCloseCreateTaskModal?: () => void; + onCloseCreateScheduledTaskModal?: () => void; onCloseParentTaskDetail?: () => void; onCloseTaskDetails?: () => VoidOrPromise; onCreateTask?: (payload: CreateWorkspaceTaskPayload) => VoidOrPromise; + onCreateScheduledTask?: (payload: CreateScheduledTaskPayload) => VoidOrPromise; onOpenConversation?: (roomID: string) => VoidOrPromise; + onOpenCreateScheduledTaskModal?: () => void; onPlanTask?: (taskID: string) => VoidOrPromise; onRefresh?: () => VoidOrPromise; + onRunScheduledTask?: (taskID: string) => VoidOrPromise; + onSelectScheduledTask?: (taskID: string) => void; + onSelectTask?: (taskID?: string) => VoidOrPromise; onStartTask?: (taskID: string) => VoidOrPromise; + onToggleScheduledTask?: (taskID: string, enabled: boolean) => VoidOrPromise; onViewParentDetail?: (taskID: string) => VoidOrPromise; parentDetailTaskID?: string; planTaskBusy?: boolean; planningTaskID?: string; selectedTask?: WorkspaceTask | null; + selectedScheduledTaskID?: string; showCreateTaskModal?: boolean; + showCreateScheduledTaskModal?: boolean; startTaskBusy?: boolean; startingTaskID?: string; taskActionError?: string; @@ -166,6 +235,12 @@ export type TasksViewProps = { tasks?: WorkspaceTask[]; t?: TranslateFn; teams?: WorkspaceTeam[]; + scheduledTasks?: WorkspaceScheduledTask[]; + scheduledTaskRuns?: WorkspaceScheduledTaskRun[]; + createScheduledTaskBusy?: boolean; + createScheduledTaskError?: string; + scheduledTaskActionID?: string; + scheduledTaskActionError?: string; }; export function TasksView({ @@ -181,19 +256,52 @@ export function TasksView({ startTaskBusy = false, createTaskBusy = false, createTaskError = "", + createScheduledTaskBusy = false, + createScheduledTaskError = "", showCreateTaskModal = false, + showCreateScheduledTaskModal = false, + scheduledTasks = [], + scheduledTaskRuns = [], + selectedScheduledTaskID = "", + scheduledTaskActionID = "", + scheduledTaskActionError = "", parentDetailTaskID = "", planningTaskID = "", startingTaskID = "", onCloseCreateTaskModal, + onCloseCreateScheduledTaskModal, onCloseParentTaskDetail, onCloseTaskDetails, onCreateTask, + onCreateScheduledTask, onRefresh = () => {}, + onRunScheduledTask = () => {}, + onSelectScheduledTask = () => {}, + onSelectTask = () => {}, + onToggleScheduledTask = () => {}, + onOpenCreateScheduledTaskModal, onOpenConversation = () => {}, }: TasksViewProps) { + const [activeView, setActiveView] = useState<"tasks" | "scheduled">("tasks"); const parentTasks = useMemo(() => rootTasks(tasks), [tasks]); const assignmentOptions = useMemo(() => taskAssignmentOptions(teams, agents, t), [agents, t, teams]); + const scheduledAgentOptions = useMemo(() => scheduledTaskAgentOptions(agents), [agents]); + const selectedScheduledTask = useMemo( + () => scheduledTasks.find((item) => item.id === selectedScheduledTaskID) ?? scheduledTasks[0] ?? null, + [scheduledTasks, selectedScheduledTaskID], + ); + const activeGeneratedTask = useMemo(() => { + for (const run of scheduledTaskRuns) { + if (!run.task_id) { + continue; + } + const task = tasks.find((item) => item.id === run.task_id); + if (task && !isTerminalWorkspaceTaskStatus(task.status)) { + return task; + } + } + return null; + }, [scheduledTaskRuns, tasks]); const [parentDialogTaskID, setParentDialogTaskID] = useState(""); const dialogStateRootTask = useMemo( () => (parentDialogTaskID ? (parentTasks.find((item) => item.id === parentDialogTaskID) ?? null) : null), @@ -214,6 +322,8 @@ export function TasksView({ const parentColumns = useMemo(() => boardColumnsForParentTasks(parentTasks), [parentTasks]); const [createDraft, setCreateDraft] = useState(emptyCreateDraft); const [createFieldErrors, setCreateFieldErrors] = useState({}); + const [scheduledDraft, setScheduledDraft] = useState(emptyScheduledTaskDraft); + const [scheduledFieldErrors, setScheduledFieldErrors] = useState({}); useEffect(() => { if (!showCreateTaskModal) { @@ -223,6 +333,19 @@ export function TasksView({ setCreateFieldErrors({}); }, [showCreateTaskModal]); + useEffect(() => { + if (!showCreateScheduledTaskModal) { + return; + } + const now = new Date(); + setScheduledDraft({ + ...emptyScheduledTaskDraft, + date: todayInputValue(now), + time: timeInputValue(now), + }); + setScheduledFieldErrors({}); + }, [showCreateScheduledTaskModal]); + async function submitCreateTask() { const title = truncateTaskTitle(createDraft.title.trim()); const description = createDraft.description.trim(); @@ -267,6 +390,57 @@ export function TasksView({ }); } + async function submitCreateScheduledTask() { + const title = truncateTaskTitle(scheduledDraft.title.trim()); + const prompt = scheduledDraft.prompt.trim(); + const firstRunAt = localDateTimeISO(scheduledDraft.date, scheduledDraft.time); + const nextFieldErrors: ScheduledTaskCreateFieldErrors = {}; + if (!title) { + nextFieldErrors.title = t("taskTitleRequired"); + } + if (!scheduledDraft.agentID) { + nextFieldErrors.agentID = t("scheduledTaskAgentRequired"); + } + if (!prompt) { + nextFieldErrors.prompt = t("scheduledTaskPromptRequired"); + } + if (!scheduledDraft.date || !firstRunAt) { + nextFieldErrors.date = t("scheduledTaskDateRequired"); + } + if (!scheduledDraft.time || !firstRunAt) { + nextFieldErrors.time = t("scheduledTaskTimeRequired"); + } + if (Object.keys(nextFieldErrors).length) { + setScheduledFieldErrors(nextFieldErrors); + return; + } + setScheduledFieldErrors({}); + const payload: CreateScheduledTaskPayload = { + title, + agent_id: scheduledDraft.agentID, + prompt, + recurrence: scheduledDraft.recurrence, + first_run_at: firstRunAt, + enabled: true, + }; + if (scheduledDraft.expiresDate) { + payload.expires_at = localDateTimeISO(scheduledDraft.expiresDate, "23:59"); + } + await onCreateScheduledTask?.(payload); + setActiveView("scheduled"); + } + + function clearScheduledFieldError(field: keyof ScheduledTaskCreateFieldErrors) { + setScheduledFieldErrors((current) => { + if (!current[field]) { + return current; + } + const next = { ...current }; + delete next[field]; + return next; + }); + } + function openRootTaskDetail(task: WorkspaceTask) { setParentDialogTaskID(task.id); } @@ -277,18 +451,51 @@ export function TasksView({ void onCloseTaskDetails?.(); } + function openRunTask(taskID: string) { + const target = tasks.find((item) => item.id === taskID) ?? null; + const rootTask = rootTaskForTask(tasks, target) ?? target; + if (rootTask) { + setParentDialogTaskID(rootTask.id); + return; + } + void onSelectTask(taskID); + } + return (
{error ?
{error}
: null} {taskActionError ? (
{taskActionError}
) : null} + {scheduledTaskActionError ? ( +
{scheduledTaskActionError}
+ ) : null} {!error ? (

{t("mainTaskBoardTitle")}

+
+ + +
+ {activeView === "tasks" ? (
{parentColumns.map((column) => ( @@ -339,6 +547,112 @@ export function TasksView({ ))}
+ ) : ( +
+
+
+ {t("scheduledTasksTab")} + +
+ {scheduledTasks.length ? ( + scheduledTasks.map((item) => ( + + )) + ) : ( +
{t("scheduledTasksEmpty")}
+ )} +
+
+ {selectedScheduledTask ? ( + <> +
+
+

{selectedScheduledTask.title}

+

{scheduledTaskRecurrenceLabel(selectedScheduledTask.recurrence, t)}

+
+
+ {selectedScheduledTask.enabled || selectedScheduledTask.next_run_at ? ( + + ) : ( + + )} + +
+
+
+
+
{t("scheduledTaskAgentLabel")}
+
{selectedScheduledTask.agent_id}
+
+
+
{t("scheduledTaskNextRunLabel")}
+
{formatTaskUpdatedAt(selectedScheduledTask.next_run_at)}
+
+
+
{t("scheduledTaskLastRunLabel")}
+
{selectedScheduledTask.last_run_at ? formatTaskUpdatedAt(selectedScheduledTask.last_run_at) : "-"}
+
+
+
{selectedScheduledTask.prompt}
+
+

{t("scheduledTaskRunsTitle")}

+ {scheduledTaskRuns.length ? ( + scheduledTaskRuns.map((run) => ( +
+ {formatTaskUpdatedAt(run.triggered_at)} + {run.status === "failed" ? t("scheduledTaskRunFailedStatus") : t("scheduledTaskRunTriggeredStatus")} + {run.task_id ? ( + + ) : null} + {run.error ? {run.error} : null} +
+ )) + ) : ( +
{t("scheduledTaskRunsEmpty")}
+ )} +
+ + ) : ( +
{t("scheduledTasksEmpty")}
+ )} +
+
+ )}
) : null} @@ -429,6 +743,17 @@ export function TasksView({ ) : null} + @@ -445,6 +770,127 @@ export function TasksView({ + (!open ? onCloseCreateScheduledTaskModal?.() : null)} + > + + +
+ {t("scheduledTaskCreateTitle")} + {t("scheduledTaskCreateSubtitle")} +
+ +
+ +
+ +