diff --git a/.gitignore b/.gitignore index 4c119ef8fd..099728498a 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,8 @@ normalized-*.jsonl # Local worktrees (not the backend-managed tree under ~/.kandev) /.worktrees/ + +# PR screenshot/asset scratch dirs — never committed; drag-and-drop into the +# GitHub PR description instead (see apps/web/e2e/scripts/upload-pr-assets.ts). +/.pr-screenshots/ +/apps/web/.pr-assets/ diff --git a/apps/backend/internal/automation/handlers.go b/apps/backend/internal/automation/handlers.go index cceeee3155..f9e74d51d8 100644 --- a/apps/backend/internal/automation/handlers.go +++ b/apps/backend/internal/automation/handlers.go @@ -31,6 +31,8 @@ func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service, log *logger.Log dispatcher.RegisterFunc(ws.ActionAutomationTriggerDelete, wsDeleteTrigger(svc, log)) dispatcher.RegisterFunc(ws.ActionAutomationTriggerTypes, wsTriggerTypes()) dispatcher.RegisterFunc(ws.ActionAutomationWebhookRevealSecret, wsRevealWebhookSecret(svc, log)) + dispatcher.RegisterFunc(ws.ActionAutomationRunDelete, wsDeleteRun(svc, log)) + dispatcher.RegisterFunc(ws.ActionAutomationRunsDeleteAll, wsDeleteAllRuns(svc, log)) } func registerHTTPRoutes(router *gin.Engine, svc *Service, log *logger.Logger) { @@ -290,3 +292,67 @@ func wsRevealWebhookSecret(svc *Service, _ *logger.Logger) func(ctx context.Cont return ws.NewResponse(msg.ID, msg.Action, &RevealWebhookSecretResponse{WebhookSecret: a.WebhookSecret}) } } + +func wsDeleteRun(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + payload, _ := parseMap(msg) + runID, _ := payload["run_id"].(string) + if runID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "run_id required", nil) + } + workspaceID, _ := payload["workspace_id"].(string) + if workspaceID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspace_id required", nil) + } + // The WS layer is not itself workspace-scoped, so we resolve the run's + // automation and reject the request when it belongs to a different + // workspace — otherwise any authenticated client who knows a foreign + // run_id could permanently delete it. NotFound (not Forbidden) avoids + // disclosing whether the id exists at all, mirroring wsRevealWebhookSecret. + run, err := svc.GetRun(ctx, runID) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + if run == nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "run not found", nil) + } + a, err := svc.GetAutomation(ctx, run.AutomationID) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + if a == nil || a.WorkspaceID != workspaceID { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "run not found", nil) + } + if err := svc.DeleteRun(ctx, runID); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"deleted": true}) + } +} + +func wsDeleteAllRuns(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + payload, _ := parseMap(msg) + automationID, _ := payload["automation_id"].(string) + if automationID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "automation_id required", nil) + } + workspaceID, _ := payload["workspace_id"].(string) + if workspaceID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspace_id required", nil) + } + // See wsDeleteRun: the WS layer isn't workspace-scoped, so this + // destructive bulk delete must verify ownership itself. + a, err := svc.GetAutomation(ctx, automationID) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + if a == nil || a.WorkspaceID != workspaceID { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "automation not found", nil) + } + if err := svc.DeleteAllRuns(ctx, automationID); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"deleted": true}) + } +} diff --git a/apps/backend/internal/automation/handlers_test.go b/apps/backend/internal/automation/handlers_test.go index b8f950b614..88d62553b6 100644 --- a/apps/backend/internal/automation/handlers_test.go +++ b/apps/backend/internal/automation/handlers_test.go @@ -186,3 +186,227 @@ func TestWsRevealWebhookSecret_RejectsCrossWorkspace(t *testing.T) { t.Errorf("expected NOT_FOUND to avoid disclosing existence, got %q", ep.Code) } } + +func TestWsDeleteRun_DeletesRun(t *testing.T) { + svc := newTestService(t) + log, _ := logger.NewFromZap(zap.NewNop()) + ctx := context.Background() + + // Create an automation and a run. + a := &Automation{WorkspaceID: "ws-1", Name: "X", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := svc.store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + run := &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusSkipped, + TriggerData: json.RawMessage(`{}`), + } + if err := svc.store.CreateRun(ctx, run); err != nil { + t.Fatal(err) + } + + req, err := ws.NewRequest("req-del", ws.ActionAutomationRunDelete, map[string]string{"run_id": run.ID, "workspace_id": "ws-1"}) + if err != nil { + t.Fatal(err) + } + resp, err := wsDeleteRun(svc, log)(ctx, req) + if err != nil { + t.Fatal(err) + } + if resp.Type != ws.MessageTypeResponse { + t.Fatalf("expected response, got %v: %s", resp.Type, string(resp.Payload)) + } + + // Run should be gone. + got, _ := svc.store.GetRun(ctx, run.ID) + if got != nil { + t.Error("expected run to be deleted") + } +} + +func TestWsDeleteRun_RequiresRunID(t *testing.T) { + svc := newTestService(t) + log, _ := logger.NewFromZap(zap.NewNop()) + ctx := context.Background() + + req, err := ws.NewRequest("req-1", ws.ActionAutomationRunDelete, map[string]string{"workspace_id": "ws-1"}) + if err != nil { + t.Fatal(err) + } + resp, err := wsDeleteRun(svc, log)(ctx, req) + if err != nil { + t.Fatal(err) + } + var ep struct { + Code string `json:"code"` + } + _ = json.Unmarshal(resp.Payload, &ep) + if ep.Code != ws.ErrorCodeBadRequest { + t.Errorf("expected BAD_REQUEST, got %q", ep.Code) + } +} + +func TestWsDeleteRun_RequiresWorkspaceID(t *testing.T) { + svc := newTestService(t) + log, _ := logger.NewFromZap(zap.NewNop()) + ctx := context.Background() + + req, err := ws.NewRequest("req-1", ws.ActionAutomationRunDelete, map[string]string{"run_id": "run-1"}) + if err != nil { + t.Fatal(err) + } + resp, err := wsDeleteRun(svc, log)(ctx, req) + if err != nil { + t.Fatal(err) + } + var ep ws.ErrorPayload + _ = json.Unmarshal(resp.Payload, &ep) + if ep.Code != ws.ErrorCodeBadRequest { + t.Errorf("expected BAD_REQUEST, got %q", ep.Code) + } +} + +func TestWsDeleteRun_RejectsCrossWorkspace(t *testing.T) { + svc := newTestService(t) + log, _ := logger.NewFromZap(zap.NewNop()) + ctx := context.Background() + + // Run belongs to an automation in workspace A. + a := &Automation{WorkspaceID: "ws-A", Name: "X", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := svc.store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + run := &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusSkipped, + TriggerData: json.RawMessage(`{}`), + } + if err := svc.store.CreateRun(ctx, run); err != nil { + t.Fatal(err) + } + + // A caller in workspace B must not be able to delete it. + req, _ := ws.NewRequest("req-1", ws.ActionAutomationRunDelete, map[string]any{"run_id": run.ID, "workspace_id": "ws-B"}) + resp, err := wsDeleteRun(svc, log)(ctx, req) + if err != nil { + t.Fatal(err) + } + if resp.Type != ws.MessageTypeError { + t.Fatalf("expected error response for cross-workspace delete, got %v: %s", resp.Type, string(resp.Payload)) + } + var ep ws.ErrorPayload + if err := json.Unmarshal(resp.Payload, &ep); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if ep.Code != ws.ErrorCodeNotFound { + t.Errorf("expected NOT_FOUND to avoid disclosing existence, got %q", ep.Code) + } + + // The run must survive the rejected request. + got, _ := svc.store.GetRun(ctx, run.ID) + if got == nil { + t.Error("run was deleted despite cross-workspace mismatch — regression") + } +} + +func TestWsDeleteAllRuns_ClearsAllRuns(t *testing.T) { + svc := newTestService(t) + log, _ := logger.NewFromZap(zap.NewNop()) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "Y", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := svc.store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + for range 3 { + if err := svc.store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusSkipped, + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + } + + req, err := ws.NewRequest("req-all", ws.ActionAutomationRunsDeleteAll, map[string]string{"automation_id": a.ID, "workspace_id": "ws-1"}) + if err != nil { + t.Fatal(err) + } + resp, err := wsDeleteAllRuns(svc, log)(ctx, req) + if err != nil { + t.Fatal(err) + } + if resp.Type != ws.MessageTypeResponse { + t.Fatalf("expected response, got %v: %s", resp.Type, string(resp.Payload)) + } + + runs, _ := svc.store.ListRuns(ctx, a.ID, 50) + if len(runs) != 0 { + t.Errorf("expected 0 runs after delete-all, got %d", len(runs)) + } +} + +func TestWsDeleteAllRuns_RequiresWorkspaceID(t *testing.T) { + svc := newTestService(t) + log, _ := logger.NewFromZap(zap.NewNop()) + ctx := context.Background() + + req, err := ws.NewRequest("req-1", ws.ActionAutomationRunsDeleteAll, map[string]string{"automation_id": "auto-1"}) + if err != nil { + t.Fatal(err) + } + resp, err := wsDeleteAllRuns(svc, log)(ctx, req) + if err != nil { + t.Fatal(err) + } + var ep ws.ErrorPayload + _ = json.Unmarshal(resp.Payload, &ep) + if ep.Code != ws.ErrorCodeBadRequest { + t.Errorf("expected BAD_REQUEST, got %q", ep.Code) + } +} + +func TestWsDeleteAllRuns_RejectsCrossWorkspace(t *testing.T) { + svc := newTestService(t) + log, _ := logger.NewFromZap(zap.NewNop()) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-A", Name: "Y", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := svc.store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + if err := svc.store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusSkipped, + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + + req, _ := ws.NewRequest("req-1", ws.ActionAutomationRunsDeleteAll, map[string]any{"automation_id": a.ID, "workspace_id": "ws-B"}) + resp, err := wsDeleteAllRuns(svc, log)(ctx, req) + if err != nil { + t.Fatal(err) + } + if resp.Type != ws.MessageTypeError { + t.Fatalf("expected error response for cross-workspace delete-all, got %v: %s", resp.Type, string(resp.Payload)) + } + var ep ws.ErrorPayload + if err := json.Unmarshal(resp.Payload, &ep); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if ep.Code != ws.ErrorCodeNotFound { + t.Errorf("expected NOT_FOUND to avoid disclosing existence, got %q", ep.Code) + } + + // Runs must survive the rejected request. + runs, _ := svc.store.ListRuns(ctx, a.ID, 50) + if len(runs) != 1 { + t.Errorf("expected the run to survive a rejected cross-workspace delete-all, got %d runs", len(runs)) + } +} diff --git a/apps/backend/internal/automation/service.go b/apps/backend/internal/automation/service.go index be76abbedd..8e75237190 100644 --- a/apps/backend/internal/automation/service.go +++ b/apps/backend/internal/automation/service.go @@ -3,7 +3,9 @@ package automation import ( "context" "encoding/json" + "errors" "fmt" + "sync" "time" "go.uber.org/zap" @@ -13,11 +15,36 @@ import ( "github.com/kandev/kandev/internal/events/bus" ) +// ErrTaskNotFound is the sentinel that run-cleanup paths check to distinguish +// "the task is already gone — fine, drop the run row anyway" from a real +// upstream failure. TaskDeleter implementations should wrap this when the +// task domain reports a missing row, so this package can recognize the case +// via errors.Is without importing the task repository package (see +// backendapp's taskDeleterAdapter for the production wiring). +var ErrTaskNotFound = errors.New("automation: task not found for cleanup") + +// TaskDeleter deletes a task and cleans up its resources. +// Satisfied by *taskservice.Service; injected to avoid a cyclic import. +// Implementations should return errors wrapping ErrTaskNotFound when the +// task is already gone. +type TaskDeleter interface { + DeleteTask(ctx context.Context, id string) error +} + // Service coordinates automation operations. type Service struct { - store *Store - eventBus bus.EventBus - logger *logger.Logger + store *Store + eventBus bus.EventBus + logger *logger.Logger + taskDeleter TaskDeleter // optional; nil-safe + + // runLocks serializes run creation (RecordRun, the concurrency-cap skip + // insert) against DeleteAllRuns per automation ID. Without this, a run + // created between DeleteAllRuns' task-id snapshot and its final row + // purge would have its row deleted without its task ever reaching the + // TaskDeleter — orphaning the task. Entries are never removed: growth is + // bounded by the number of distinct automation IDs (~160 B per entry). + runLocks sync.Map // automationID (string) -> *sync.Mutex } // NewService creates a new automation service. @@ -34,6 +61,12 @@ func (s *Service) Store() *Store { return s.store } +// SetTaskDeleter wires the task deletion handler for run cleanup. +// Optional: when nil, run deletion skips task teardown. +func (s *Service) SetTaskDeleter(d TaskDeleter) { + s.taskDeleter = d +} + // --- Automation CRUD --- // CreateAutomation creates an automation with its initial triggers. @@ -174,6 +207,73 @@ func (s *Service) ListRuns(ctx context.Context, automationID string, limit int) return s.store.ListRuns(ctx, automationID, limit) } +// GetRun returns a single run by ID, or nil if not found. +func (s *Service) GetRun(ctx context.Context, id string) (*AutomationRun, error) { + return s.store.GetRun(ctx, id) +} + +// automationRunLock returns an unlock func for the per-automation mutex that +// serializes run creation (createRunLocked) against DeleteAllRuns. +func (s *Service) automationRunLock(automationID string) func() { + v, _ := s.runLocks.LoadOrStore(automationID, &sync.Mutex{}) + mu := v.(*sync.Mutex) + mu.Lock() + return mu.Unlock +} + +// createRunLocked persists a run row while holding the per-automation lock +// that DeleteAllRuns also acquires. Without this, a run created between +// DeleteAllRuns' task-id snapshot and its final row purge would be deleted +// without its task ever reaching the TaskDeleter, orphaning the task. +func (s *Service) createRunLocked(ctx context.Context, run *AutomationRun) error { + defer s.automationRunLock(run.AutomationID)() + return s.store.CreateRun(ctx, run) +} + +// DeleteRun removes a single run and its associated task (if any). +// Task deletion is best-effort: a not-found error is silently ignored so +// stale/orphaned run rows are always removable by the user. +func (s *Service) DeleteRun(ctx context.Context, runID string) error { + run, err := s.store.GetRun(ctx, runID) + if err != nil { + return fmt.Errorf("get run: %w", err) + } + if run != nil && run.TaskID != "" && s.taskDeleter != nil { + if delErr := s.taskDeleter.DeleteTask(ctx, run.TaskID); delErr != nil { + if !errors.Is(delErr, ErrTaskNotFound) { + return fmt.Errorf("delete task: %w", delErr) + } + s.logger.Debug("run task already gone, continuing delete", + zap.String("run_id", runID), + zap.String("task_id", run.TaskID)) + } + } + return s.store.DeleteRun(ctx, runID) +} + +// DeleteAllRuns removes every run for an automation, deleting each associated +// task first. Task deletion is best-effort: not-found errors are ignored. +func (s *Service) DeleteAllRuns(ctx context.Context, automationID string) error { + defer s.automationRunLock(automationID)() + if s.taskDeleter != nil { + taskIDs, err := s.store.ListRunTaskIDs(ctx, automationID) + if err != nil { + return fmt.Errorf("list run task ids: %w", err) + } + for _, taskID := range taskIDs { + if delErr := s.taskDeleter.DeleteTask(ctx, taskID); delErr != nil { + if !errors.Is(delErr, ErrTaskNotFound) { + return fmt.Errorf("delete task %s: %w", taskID, delErr) + } + s.logger.Debug("run task already gone, skipping", + zap.String("automation_id", automationID), + zap.String("task_id", taskID)) + } + } + } + return s.store.DeleteAllRuns(ctx, automationID) +} + // --- Trigger firing --- // FireTrigger publishes an AutomationTriggered event for the given trigger. @@ -248,7 +348,7 @@ func (s *Service) FireTrigger(ctx context.Context, automationID, triggerID strin // RecordRun records a trigger run outcome. func (s *Service) RecordRun(ctx context.Context, run *AutomationRun) error { - return s.store.CreateRun(ctx, run) + return s.createRunLocked(ctx, run) } // maybeSkipForConcurrencyCap enforces max_concurrent_runs. Returns (skipped, @@ -278,7 +378,7 @@ func (s *Service) maybeSkipForConcurrencyCap(ctx context.Context, automationID, TriggerData: triggerData, ErrorMessage: fmt.Sprintf("max_concurrent_runs=%d reached", a.MaxConcurrentRuns), } - if recErr := s.store.CreateRun(ctx, skipRun); recErr != nil { + if recErr := s.createRunLocked(ctx, skipRun); recErr != nil { s.logger.Warn("failed to record skipped run", zap.Error(recErr)) } s.logger.Info("automation trigger skipped: concurrency cap reached", diff --git a/apps/backend/internal/automation/service_test.go b/apps/backend/internal/automation/service_test.go new file mode 100644 index 0000000000..a9f7c57b0d --- /dev/null +++ b/apps/backend/internal/automation/service_test.go @@ -0,0 +1,347 @@ +package automation + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" + + "github.com/jmoiron/sqlx" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" +) + +// fakeTaskDeleter records deletions and can inject errors per task ID. +type fakeTaskDeleter struct { + deleted []string + errors map[string]error +} + +func (f *fakeTaskDeleter) DeleteTask(_ context.Context, id string) error { + f.deleted = append(f.deleted, id) + if f.errors != nil { + if err, ok := f.errors[id]; ok { + return err + } + } + return nil +} + +func TestService_DeleteRun_CallsTaskDeleter(t *testing.T) { + svc := newTestService(t) + deleter := &fakeTaskDeleter{} + svc.SetTaskDeleter(deleter) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "A", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := svc.store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + run := &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusTaskCreated, + TaskID: "task-xyz", + TriggerData: json.RawMessage(`{}`), + } + if err := svc.store.CreateRun(ctx, run); err != nil { + t.Fatal(err) + } + + if err := svc.DeleteRun(ctx, run.ID); err != nil { + t.Fatalf("DeleteRun: %v", err) + } + + // Task deleter must have been called. + if len(deleter.deleted) != 1 || deleter.deleted[0] != "task-xyz" { + t.Errorf("expected DeleteTask(task-xyz), got %v", deleter.deleted) + } + // Run row must be gone. + got, _ := svc.store.GetRun(ctx, run.ID) + if got != nil { + t.Error("expected run row to be removed") + } +} + +func TestService_DeleteRun_TaskNotFound_StillDeletesRun(t *testing.T) { + svc := newTestService(t) + deleter := &fakeTaskDeleter{ + errors: map[string]error{"task-gone": ErrTaskNotFound}, + } + svc.SetTaskDeleter(deleter) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "B", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := svc.store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + run := &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusSkipped, + TaskID: "task-gone", + TriggerData: json.RawMessage(`{}`), + } + if err := svc.store.CreateRun(ctx, run); err != nil { + t.Fatal(err) + } + + // Must succeed even though the task is not found. + if err := svc.DeleteRun(ctx, run.ID); err != nil { + t.Fatalf("DeleteRun with not-found task: %v", err) + } + + // Run row must still be gone. + got, _ := svc.store.GetRun(ctx, run.ID) + if got != nil { + t.Error("expected run row to be removed despite task-not-found") + } +} + +func TestService_DeleteAllRuns_CallsTaskDeleterForEach(t *testing.T) { + svc := newTestService(t) + deleter := &fakeTaskDeleter{} + svc.SetTaskDeleter(deleter) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "C", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := svc.store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + taskIDs := []string{"task-1", "task-2", "task-3"} + for _, tid := range taskIDs { + if err := svc.store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusTaskCreated, + TaskID: tid, + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + } + // Also one run with no task_id (fire-and-forget / skipped). + if err := svc.store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusSkipped, + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + + if err := svc.DeleteAllRuns(ctx, a.ID); err != nil { + t.Fatalf("DeleteAllRuns: %v", err) + } + + // All three task IDs must have been passed to DeleteTask. + if len(deleter.deleted) != 3 { + t.Errorf("expected 3 task deletions, got %d: %v", len(deleter.deleted), deleter.deleted) + } + // All run rows gone. + runs, _ := svc.store.ListRuns(ctx, a.ID, 50) + if len(runs) != 0 { + t.Errorf("expected 0 runs, got %d", len(runs)) + } +} + +func TestService_DeleteAllRuns_TaskNotFound_StillClearsRuns(t *testing.T) { + svc := newTestService(t) + deleter := &fakeTaskDeleter{ + errors: map[string]error{"task-stale": ErrTaskNotFound}, + } + svc.SetTaskDeleter(deleter) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "D", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := svc.store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + for _, tid := range []string{"task-stale", "task-ok"} { + if err := svc.store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusTaskCreated, + TaskID: tid, + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + } + + if err := svc.DeleteAllRuns(ctx, a.ID); err != nil { + t.Fatalf("DeleteAllRuns with not-found task: %v", err) + } + + runs, _ := svc.store.ListRuns(ctx, a.ID, 50) + if len(runs) != 0 { + t.Errorf("expected 0 runs after delete-all, got %d", len(runs)) + } +} + +// TestService_DeleteAllRuns_SerializesAgainstConcurrentRecordRun is a +// regression guard for the orphaned-task race: DeleteAllRuns snapshots task +// IDs via ListRunTaskIDs and then issues a broad DELETE by automation_id. If +// a run were recorded in between, its row would be purged by the broad +// DELETE without its task ever reaching the TaskDeleter. The per-automation +// run lock must make RecordRun block for the full duration of DeleteAllRuns. +func TestService_DeleteAllRuns_SerializesAgainstConcurrentRecordRun(t *testing.T) { + svc := newTestService(t) + svc.SetTaskDeleter(&fakeTaskDeleter{}) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "Race", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := svc.store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + if err := svc.store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusTaskCreated, + TaskID: "task-existing", + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + + // Simulate DeleteAllRuns being mid-flight by holding its lock directly. + // Register cleanup immediately, before any t.Fatal path, so a failure in + // the select below (RecordRun completing too early) can't leak the lock + // for the rest of the test binary's life. sync.Once makes the explicit + // unlock() call below and this cleanup mutually safe against a double + // Unlock() panic. + unlock := svc.automationRunLock(a.ID) + var unlockOnce sync.Once + safeUnlock := func() { unlockOnce.Do(unlock) } + t.Cleanup(safeUnlock) + + done := make(chan error, 1) + go func() { + done <- svc.RecordRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusTaskCreated, + TaskID: "task-new", + TriggerData: json.RawMessage(`{}`), + }) + }() + + select { + case <-done: + t.Fatal("RecordRun completed while the automation run lock was held — DeleteAllRuns is not serialized against run creation") + case <-time.After(50 * time.Millisecond): + // Expected: RecordRun is still blocked on the lock. + } + + safeUnlock() + + if err := <-done; err != nil { + t.Fatalf("RecordRun: %v", err) + } + + runs, _ := svc.store.ListRuns(ctx, a.ID, 50) + if len(runs) != 2 { + t.Errorf("expected 2 runs (pre-existing + the one recorded after unlock), got %d", len(runs)) + } +} + +// TestDeleteAllRuns_AutomationSurvives is a regression guard: deleting all run +// rows — including issuing real DELETE SQL against task rows in the shared +// in-memory DB — must never delete the parent automation row. A real DB-level +// deleter catches SQL trigger / ON DELETE CASCADE regressions. Note: event +// handler side-effects are not covered here (no orchestrator runs in this test). +func TestDeleteAllRuns_AutomationSurvives(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + + // Create a minimal tasks table in the same in-memory DB so the + // real-deleter can insert and then DELETE task rows. + if _, err := store.db.ExecContext(ctx, + `CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, title TEXT, state TEXT)`); err != nil { + t.Fatal("create tasks table:", err) + } + + // sqliteTaskDeleter deletes from the real tasks table in the same DB — + // any SQL cascade or trigger that touched automations would fire here. + realDeleter := &sqliteTaskDeleter{db: store.db} + + log, _ := logger.NewFromZap(zap.NewNop()) + eb := bus.NewMemoryEventBus(log) + svc := NewService(store, eb, log) + svc.SetTaskDeleter(realDeleter) + + a := &Automation{WorkspaceID: "ws-1", Name: "Survives", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + + // Insert real task rows and create runs referencing them. + taskIDs := []string{"task-a", "task-b", "task-c"} + for _, tid := range taskIDs { + if _, err := store.db.ExecContext(ctx, + `INSERT INTO tasks (id, title, state) VALUES (?, 'Test task', 'running')`, tid); err != nil { + t.Fatal("insert task:", err) + } + if err := store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusTaskCreated, + TaskID: tid, + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + } + // Skipped runs without task IDs. + for range 3 { + if err := store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, TriggerType: TriggerTypeScheduled, + Status: RunStatusSkipped, TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + } + + if err := svc.DeleteAllRuns(ctx, a.ID); err != nil { + t.Fatalf("DeleteAllRuns: %v", err) + } + + // Automation row must still exist after real task DELETEs fired. + got, err := store.GetAutomation(ctx, a.ID) + if err != nil { + t.Fatalf("GetAutomation after DeleteAllRuns: %v", err) + } + if got == nil { + t.Error("automation was deleted by DeleteAllRuns — regression") + return + } + if got.Name != "Survives" { + t.Errorf("unexpected automation name %q", got.Name) + } + + // Runs must be gone. + runs, _ := store.ListRuns(ctx, a.ID, 50) + if len(runs) != 0 { + t.Errorf("expected 0 runs, got %d", len(runs)) + } + // Task rows should have been deleted. + if len(realDeleter.deleted) != 3 { + t.Errorf("expected 3 task deletions, got %d: %v", len(realDeleter.deleted), realDeleter.deleted) + } +} + +// sqliteTaskDeleter deletes from the real tasks table in the same in-memory +// DB, so any SQL trigger or ON DELETE CASCADE that touches automations fires. +type sqliteTaskDeleter struct { + db *sqlx.DB + deleted []string +} + +func (d *sqliteTaskDeleter) DeleteTask(ctx context.Context, id string) error { + d.deleted = append(d.deleted, id) + _, err := d.db.ExecContext(ctx, `DELETE FROM tasks WHERE id = ?`, id) + return err +} diff --git a/apps/backend/internal/automation/store.go b/apps/backend/internal/automation/store.go index f70823195e..4f8fb28f99 100644 --- a/apps/backend/internal/automation/store.go +++ b/apps/backend/internal/automation/store.go @@ -471,6 +471,43 @@ func (s *Store) CountActiveRuns(ctx context.Context, automationID string) (int, return count, err } +// GetRun returns a single run by ID, or nil if not found. +func (s *Store) GetRun(ctx context.Context, id string) (*AutomationRun, error) { + var r AutomationRun + err := s.ro.GetContext(ctx, &r, + `SELECT * FROM automation_runs WHERE id = ?`, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + r.TriggerData = json.RawMessage(r.TriggerDataJSON) + return &r, nil +} + +// DeleteRun removes a single run row. +func (s *Store) DeleteRun(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM automation_runs WHERE id = ?`, id) + return err +} + +// ListRunTaskIDs returns all non-empty task_id values for an automation's runs. +// Used by DeleteAllRuns so the service can clean up tasks before purging rows. +func (s *Store) ListRunTaskIDs(ctx context.Context, automationID string) ([]string, error) { + var ids []string + err := s.ro.SelectContext(ctx, &ids, + `SELECT task_id FROM automation_runs WHERE automation_id = ? AND task_id != ''`, + automationID) + return ids, err +} + +// DeleteAllRuns removes every run row for an automation. +func (s *Store) DeleteAllRuns(ctx context.Context, automationID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM automation_runs WHERE automation_id = ?`, automationID) + return err +} + // DeleteAutomationsByWorkspace removes all automations (and their triggers/runs) for a workspace. // Used by e2e reset. func (s *Store) DeleteAutomationsByWorkspace(ctx context.Context, workspaceID string) (int, error) { diff --git a/apps/backend/internal/automation/store_test.go b/apps/backend/internal/automation/store_test.go index 373be78a7f..a97108e991 100644 --- a/apps/backend/internal/automation/store_test.go +++ b/apps/backend/internal/automation/store_test.go @@ -212,3 +212,89 @@ func TestDeleteAutomation(t *testing.T) { t.Error("expected nil after delete") } } + +func TestDeleteRun(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "A", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + run := &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusSkipped, + TaskID: "task-abc", + TriggerData: json.RawMessage(`{}`), + } + if err := store.CreateRun(ctx, run); err != nil { + t.Fatal(err) + } + + // GetRun finds it. + got, err := store.GetRun(ctx, run.ID) + if err != nil { + t.Fatal(err) + } + if got == nil { + t.Error("expected run, got nil") + return + } + if got.TaskID != "task-abc" { + t.Errorf("expected task_id 'task-abc', got %q", got.TaskID) + } + + // DeleteRun removes it. + if err := store.DeleteRun(ctx, run.ID); err != nil { + t.Fatal(err) + } + got, err = store.GetRun(ctx, run.ID) + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Error("expected nil after delete, got run") + } +} + +func TestDeleteAllRuns(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "B", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + for i := 0; i < 3; i++ { + if err := store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusSkipped, + TaskID: "task-" + string(rune('0'+i)), + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + } + + taskIDs, err := store.ListRunTaskIDs(ctx, a.ID) + if err != nil { + t.Fatal(err) + } + if len(taskIDs) != 3 { + t.Fatalf("expected 3 task IDs, got %d", len(taskIDs)) + } + + if err := store.DeleteAllRuns(ctx, a.ID); err != nil { + t.Fatal(err) + } + + runs, err := store.ListRuns(ctx, a.ID, 50) + if err != nil { + t.Fatal(err) + } + if len(runs) != 0 { + t.Errorf("expected 0 runs after delete, got %d", len(runs)) + } +} diff --git a/apps/backend/internal/backendapp/e2e_reset.go b/apps/backend/internal/backendapp/e2e_reset.go index 581a70ac53..d10baf17b7 100644 --- a/apps/backend/internal/backendapp/e2e_reset.go +++ b/apps/backend/internal/backendapp/e2e_reset.go @@ -18,6 +18,7 @@ import ( // errKey is the JSON field used for error responses from the E2E endpoints. const errKey = "error" +const statusKey = "status" // registerE2EResetRoutes registers the E2E test-only endpoints. // The endpoints are available when KANDEV_MOCK_AGENT is "true" or "only" (dev/E2E modes). @@ -40,6 +41,12 @@ func registerE2EResetRoutes( // workflow path (e.g. improve-kandev) without depending on the real // bootstrap endpoint, which clones from GitHub and shells out to gh. api.POST("/hidden-workflow", handleE2ECreateHiddenWorkflow(taskSvc, log)) + // Automation seeding helpers for E2E tests that need runs without going + // through the WS API (works on any Node version). + if automationSvc != nil { + api.POST("/automations", handleE2ECreateAutomation(automationSvc, log)) + api.POST("/automation-runs", handleE2ECreateAutomationRun(automationSvc, log)) + } log.Info("registered E2E endpoints (test-only)") } @@ -215,3 +222,67 @@ func handleE2ECreateHiddenWorkflow(taskSvc *taskservice.Service, log *logger.Log }) } } + +type e2eCreateAutomationRequest struct { + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` +} + +// handleE2ECreateAutomation seeds an automation for E2E tests via HTTP so tests +// don't require Node 24 (WS API requires a global WebSocket that isn't in Node 20). +func handleE2ECreateAutomation(svc *automation.Service, log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + var body e2eCreateAutomationRequest + if err := c.ShouldBindJSON(&body); err != nil || body.WorkspaceID == "" || body.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{errKey: "workspace_id and name are required"}) + return + } + a, err := svc.CreateAutomation(c.Request.Context(), &automation.CreateAutomationRequest{ + WorkspaceID: body.WorkspaceID, + Name: body.Name, + WorkflowID: body.WorkflowID, + WorkflowStepID: body.WorkflowStepID, + MaxConcurrentRuns: 10, + }) + if err != nil { + log.Error("e2e: failed to create automation", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"id": a.ID, "workspace_id": a.WorkspaceID, "name": a.Name}) + } +} + +type e2eCreateAutomationRunRequest struct { + AutomationID string `json:"automation_id"` + Status string `json:"status"` +} + +// handleE2ECreateAutomationRun seeds an automation run row for E2E tests. +func handleE2ECreateAutomationRun(svc *automation.Service, log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + var body e2eCreateAutomationRunRequest + if err := c.ShouldBindJSON(&body); err != nil || body.AutomationID == "" { + c.JSON(http.StatusBadRequest, gin.H{errKey: "automation_id is required"}) + return + } + status := automation.RunStatus(body.Status) + if status == "" { + status = automation.RunStatusSkipped + } + run := &automation.AutomationRun{ + AutomationID: body.AutomationID, + TriggerType: automation.TriggerTypeScheduled, + Status: status, + TriggerData: []byte(`{}`), + } + if err := svc.RecordRun(c.Request.Context(), run); err != nil { + log.Error("e2e: failed to create automation run", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"id": run.ID, "automation_id": run.AutomationID, statusKey: run.Status}) + } +} diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 81a0a80705..2a691eb2fb 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -120,6 +120,9 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories if automationErr != nil { log.Warn("Automation service initialization failed (non-fatal)", zap.Error(automationErr)) } + if automationComponents != nil { + automationComponents.Service.SetTaskDeleter(&automationTaskDeleterAdapter{svc: taskSvc}) + } return &Services{ Task: taskSvc, diff --git a/apps/backend/internal/backendapp/turn_adapters.go b/apps/backend/internal/backendapp/turn_adapters.go index 1d3c786cd4..6937257d12 100644 --- a/apps/backend/internal/backendapp/turn_adapters.go +++ b/apps/backend/internal/backendapp/turn_adapters.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "github.com/kandev/kandev/internal/automation" "github.com/kandev/kandev/internal/github" "github.com/kandev/kandev/internal/task/models" taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" @@ -123,6 +124,26 @@ func (a *taskDeleterAdapter) translateDeleteErr(err error) error { return err } +// automationTaskDeleterAdapter satisfies automation.TaskDeleter and +// translates the task repository's ErrTaskNotFound sentinel to +// automation.ErrTaskNotFound so the automation run-cleanup paths can +// classify the "already gone" case via errors.Is without importing the task +// repository's package. +type automationTaskDeleterAdapter struct { + svc *taskservice.Service +} + +func (a *automationTaskDeleterAdapter) DeleteTask(ctx context.Context, taskID string) error { + err := a.svc.DeleteTask(ctx, taskID) + if err == nil { + return nil + } + if errors.Is(err, taskrepo.ErrTaskNotFound) { + return fmt.Errorf("%w: %w", automation.ErrTaskNotFound, err) + } + return err +} + // repositoryLookupAdapter satisfies the linear/jira/sentry RepositoryLookup // interface over the task service. It is the validation seam for a watcher's // optional repository binding. The task service's GetRepository filters diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 333e89ddc8..53134d7e93 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -553,6 +553,8 @@ const ( ActionAutomationTriggerDelete = "automation.trigger.delete" ActionAutomationTriggerTypes = "automation.trigger_types" ActionAutomationWebhookRevealSecret = "automation.webhook.reveal_secret" + ActionAutomationRunDelete = "automation.run.delete" + ActionAutomationRunsDeleteAll = "automation.runs.delete_all" ) // Error codes diff --git a/apps/web/components/automations/automation-editor.tsx b/apps/web/components/automations/automation-editor.tsx index 826069fcb8..ea27dea345 100644 --- a/apps/web/components/automations/automation-editor.tsx +++ b/apps/web/components/automations/automation-editor.tsx @@ -353,23 +353,7 @@ export function AutomationEditor({ workspaceId, automationId }: AutomationEditor return (
-
- Name - updateField("name", e.target.value)} - placeholder="Automation name" - aria-describedby={!form.name.trim() ? "automation-name-help" : undefined} - aria-invalid={!form.name.trim() ? true : undefined} - /> - {!form.name.trim() && ( -

- Enter an automation name to enable saving. -

- )} -
+ updateField("name", v)} /> - + void }) { + return ( +
+ Name + onChange(e.target.value)} + placeholder="Automation name" + aria-describedby={!value.trim() ? "automation-name-help" : undefined} + aria-invalid={!value.trim() ? true : undefined} + /> + {!value.trim() && ( +

+ Enter an automation name to enable saving. +

+ )} +
+ ); +} + function CreatedWebhookDialogHost({ details, onClose, diff --git a/apps/web/components/automations/runs-section.tsx b/apps/web/components/automations/runs-section.tsx index bc34924460..e065d1cd56 100644 --- a/apps/web/components/automations/runs-section.tsx +++ b/apps/web/components/automations/runs-section.tsx @@ -2,18 +2,30 @@ import { useState } from "react"; import { useRouter } from "@/lib/routing/client-router"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@kandev/ui/alert-dialog"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Label } from "@kandev/ui/label"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; -import { IconChevronDown, IconChevronUp, IconRefresh } from "@tabler/icons-react"; +import { IconChevronDown, IconChevronUp, IconRefresh, IconTrash } from "@tabler/icons-react"; import { useAutomationRuns } from "@/hooks/domains/settings/use-automation-runs"; -import type { ExecutionMode, RunStatus } from "@/lib/types/automation"; +import type { AutomationRun, ExecutionMode, RunStatus } from "@/lib/types/automation"; import { formatRelativeTime } from "./format-utils"; type RunsSectionProps = { automationId: string | null; executionMode: ExecutionMode; + workspaceId: string; }; const STATUS_BADGE: Record< @@ -27,11 +39,105 @@ const STATUS_BADGE: Record< skipped: { variant: "outline", label: "Skipped" }, }; -export function RunsSection({ automationId, executionMode }: RunsSectionProps) { +type RunRowProps = { + run: AutomationRun; + taskClickable: boolean; + onDelete: (id: string) => void; + onNavigate: (taskId: string) => void; +}; + +function RunRow({ run, taskClickable, onDelete, onNavigate }: RunRowProps) { + const badge = STATUS_BADGE[run.status] ?? STATUS_BADGE.triggered; + const rowClickable = taskClickable && !!run.task_id; + return ( + onNavigate(run.task_id) : undefined} + > + {run.trigger_type} + + {badge.label} + + + {run.task_id ? run.task_id.slice(0, 8) : "-"} + + + {formatRelativeTime(run.created_at)} + + + {run.error_message || "-"} + + + + + + ); +} + +type DeleteAllButtonProps = { disabled: boolean; onConfirm: () => void }; + +function DeleteAllButton({ disabled, onConfirm }: DeleteAllButtonProps) { + return ( + + + + + + + Delete all runs? + + This will permanently remove all run records for this automation — including any not + currently loaded — and their associated tasks. This cannot be undone. + + + + Cancel + + Delete all + + + + + ); +} + +export function RunsSection({ automationId, executionMode, workspaceId }: RunsSectionProps) { const [expanded, setExpanded] = useState(false); - const { runs, loading, refresh } = useAutomationRuns(automationId); + const { runs, loading, refresh, deleteRun, deleteAllRuns } = useAutomationRuns( + automationId, + workspaceId, + ); const router = useRouter(); - const taskClickable = executionMode !== "run"; if (!automationId) return null; @@ -52,15 +158,19 @@ export function RunsSection({ automationId, executionMode }: RunsSectionProps) { )} {expanded && ( - +
+ + {runs.length > 0 && } +
)}
{expanded && ( @@ -73,47 +183,26 @@ export function RunsSection({ automationId, executionMode }: RunsSectionProps) { Task Time Error + {runs.length === 0 ? ( - + {loading ? "Loading..." : "No runs yet"} ) : ( - runs.map((run) => { - const badge = STATUS_BADGE[run.status] ?? STATUS_BADGE.triggered; - const rowClickable = taskClickable && !!run.task_id; - return ( - router.push(`/tasks/${run.task_id}`) : undefined - } - > - {run.trigger_type} - - {badge.label} - - - {run.task_id ? run.task_id.slice(0, 8) : "-"} - - - {formatRelativeTime(run.created_at)} - - - {run.error_message || "-"} - - - ); - }) + runs.map((run) => ( + router.push(`/tasks/${id}`)} + /> + )) )} diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index fe4c393470..324528ccd5 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -1885,6 +1885,38 @@ export class ApiClient { async probeSSHShells(executorId: string): Promise { return this.request("POST", `/api/v1/ssh/executors/${executorId}/probe-shells`); } + + /** + * Seed an automation via the E2E HTTP endpoint (avoids WS / Node 24 requirement). + * Only works when KANDEV_MOCK_AGENT is active. + */ + async seedAutomation(opts: { + workspaceId: string; + name: string; + workflowId?: string; + workflowStepId?: string; + }): Promise<{ id: string; workspace_id: string; name: string }> { + return this.request("POST", "/api/v1/e2e/automations", { + workspace_id: opts.workspaceId, + name: opts.name, + workflow_id: opts.workflowId ?? "", + workflow_step_id: opts.workflowStepId ?? "", + }); + } + + /** + * Seed an automation run row via the E2E HTTP endpoint. + * Only works when KANDEV_MOCK_AGENT is active. + */ + async seedAutomationRun( + automationId: string, + status = "skipped", + ): Promise<{ id: string; automation_id: string; status: string }> { + return this.request("POST", "/api/v1/e2e/automation-runs", { + automation_id: automationId, + status, + }); + } } // --- Jira / Linear mock payload types --- diff --git a/apps/web/e2e/tests/automations-settings.spec.ts b/apps/web/e2e/tests/automations-settings.spec.ts index e2b71d8554..223fef59f4 100644 --- a/apps/web/e2e/tests/automations-settings.spec.ts +++ b/apps/web/e2e/tests/automations-settings.spec.ts @@ -284,4 +284,69 @@ test.describe("Automations settings page", () => { const toggleAfterReload = rowAfterReload.locator('[role="switch"]'); await expect(toggleAfterReload).not.toBeChecked(); }); + + test("delete individual and all runs from Recent Runs", async ({ + testPage, + seedData, + apiClient, + }) => { + // Seed an automation and two run rows via HTTP (avoids Node-24 WS requirement). + const automation = await apiClient.seedAutomation({ + workspaceId: seedData.workspaceId, + name: "Run Delete Test", + workflowId: seedData.workflowId, + workflowStepId: seedData.startStepId, + }); + await apiClient.seedAutomationRun(automation.id, "skipped"); + await apiClient.seedAutomationRun(automation.id, "skipped"); + + // Navigate to the editor page for this automation. + await testPage.goto(`/settings/workspace/${seedData.workspaceId}/automations/${automation.id}`); + await testPage.getByTestId("automation-editor").waitFor({ state: "visible", timeout: 15_000 }); + + // Scroll to the bottom to ensure Recent Runs is visible. + const scrollContainer = testPage.getByTestId("settings-scroll-container"); + await scrollContainer.evaluate((el) => (el.scrollTop = el.scrollHeight)); + + // Expand the Recent Runs section. + const recentRunsButton = testPage.locator("button", { hasText: /Recent Runs/ }); + await recentRunsButton.waitFor({ state: "visible", timeout: 10_000 }); + await recentRunsButton.click(); + + // Wait for the table to appear with at least one row. + const tbody = testPage.locator("table tbody"); + await tbody.waitFor({ state: "visible", timeout: 5_000 }); + await expect(tbody.locator("tr")).toHaveCount(2, { timeout: 10_000 }); + + // Delete-all button should be visible in the header. + const deleteAllBtn = testPage.getByTestId("delete-all-runs"); + await expect(deleteAllBtn).toBeVisible(); + + // Before hover, the per-row delete button is transparent and + // non-interactive — Playwright's toBeVisible() would pass even with + // opacity:0, so assert the actual CSS values gating visibility/clicks. + const firstRow = tbody.locator("tr").first(); + const deleteRowBtn = firstRow.getByTestId("delete-run"); + await expect(deleteRowBtn).toHaveCSS("opacity", "0"); + await expect(deleteRowBtn).toHaveCSS("pointer-events", "none"); + + // Hover over the first row to reveal its delete button and click it. + await firstRow.hover(); + await expect(deleteRowBtn).toHaveCSS("opacity", "1"); + await expect(deleteRowBtn).toHaveCSS("pointer-events", "auto"); + await deleteRowBtn.click(); + + // One run removed — table should now have 1 row. + await expect(tbody.locator("tr")).toHaveCount(1, { timeout: 5_000 }); + + // Delete all remaining runs — click trigger, and confirm the dialog uses + // unqualified wording rather than a count that could understate runs + // beyond what's currently loaded in the table. + await deleteAllBtn.click(); + await expect(testPage.getByText(/permanently remove all run records/)).toBeVisible(); + await testPage.getByTestId("delete-all-runs-confirm").click(); + + // Table should show the empty state. + await expect(testPage.getByText("No runs yet")).toBeVisible({ timeout: 5_000 }); + }); }); diff --git a/apps/web/hooks/domains/settings/use-automation-runs.test.ts b/apps/web/hooks/domains/settings/use-automation-runs.test.ts new file mode 100644 index 0000000000..9c33a6e2dd --- /dev/null +++ b/apps/web/hooks/domains/settings/use-automation-runs.test.ts @@ -0,0 +1,316 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import type { AutomationRun } from "@/lib/types/automation"; + +type MockState = { + automationRuns: { + byAutomationId: Record; + loading: Record; + }; +}; + +let mockState: MockState = { automationRuns: { byAutomationId: {}, loading: {} } }; + +function setRuns(automationId: string, runs: AutomationRun[]) { + mockState = { + automationRuns: { + ...mockState.automationRuns, + byAutomationId: { ...mockState.automationRuns.byAutomationId, [automationId]: runs }, + }, + }; +} + +const storeActions = { + setAutomationRuns: (automationId: string, runs: AutomationRun[]) => setRuns(automationId, runs), + setAutomationRunsLoading: (automationId: string, loading: boolean) => { + mockState = { + automationRuns: { + ...mockState.automationRuns, + loading: { ...mockState.automationRuns.loading, [automationId]: loading }, + }, + }; + }, + removeAutomationRun: (automationId: string, runId: string) => { + const runs = mockState.automationRuns.byAutomationId[automationId] ?? []; + setRuns( + automationId, + runs.filter((r) => r.id !== runId), + ); + }, + clearAutomationRuns: (automationId: string) => setRuns(automationId, []), + restoreAutomationRun: (automationId: string, run: AutomationRun) => { + const runs = mockState.automationRuns.byAutomationId[automationId] ?? []; + if (runs.some((r) => r.id === run.id)) return; + setRuns(automationId, [...runs, run]); + }, +}; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (s: MockState & typeof storeActions) => unknown) => + selector({ ...mockState, ...storeActions }), + useAppStoreApi: () => ({ + getState: () => ({ ...mockState, ...storeActions }), + }), +})); + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +vi.mock("@/lib/api/domains/automation-api", () => ({ + listAutomationRuns: vi.fn(), + deleteAutomationRun: vi.fn(), + deleteAllAutomationRuns: vi.fn(), +})); + +import { toast } from "sonner"; +import { + listAutomationRuns, + deleteAutomationRun, + deleteAllAutomationRuns, +} from "@/lib/api/domains/automation-api"; +import { useAutomationRuns } from "./use-automation-runs"; + +const AUTOMATION_ID = "auto-1"; +const WORKSPACE_ID = "ws-1"; + +function mkRun(id: string): AutomationRun { + return { + id, + automation_id: AUTOMATION_ID, + trigger_id: "trig-1", + trigger_type: "scheduled", + task_id: "", + status: "skipped", + dedup_key: "", + trigger_data: {}, + error_message: "", + created_at: new Date().toISOString(), + }; +} + +/** A promise plus its resolve function, for controlling when a mocked async + * call settles relative to other events in a test. */ +function deferred() { + const { promise, resolve } = Promise.withResolvers(); + return { promise, resolve }; +} + +beforeEach(() => { + mockState = { automationRuns: { byAutomationId: {}, loading: {} } }; + vi.mocked(listAutomationRuns).mockReset(); + vi.mocked(deleteAutomationRun).mockReset(); + vi.mocked(deleteAllAutomationRuns).mockReset(); + vi.mocked(toast.error).mockReset(); +}); + +describe("useAutomationRuns", () => { + it("re-applies the optimistic removal if an in-flight refresh resurrects the row before delete confirms", async () => { + const runX = mkRun("run-x"); + const runY = mkRun("run-y"); + setRuns(AUTOMATION_ID, [runX, runY]); + + // The delete request stays pending until we manually resolve it below, + // so we can interleave a "stale refresh" in between. + const del = deferred<{ deleted: boolean }>(); + vi.mocked(deleteAutomationRun).mockReturnValue(del.promise); + // A concurrent refresh() resolves with the pre-delete list — as if the + // list request was already in flight when the delete was fired. The + // hook's own mount-effect fetch (first call) is left pending so it + // doesn't confound the explicit refresh() below. + vi.mocked(listAutomationRuns) + .mockReturnValueOnce(Promise.withResolvers().promise) + .mockResolvedValue([runX, runY]); + + const { result, rerender } = renderHook(() => useAutomationRuns(AUTOMATION_ID, WORKSPACE_ID)); + + act(() => { + result.current.deleteRun("run-x"); + }); + rerender(); + expect(result.current.runs.map((r) => r.id)).toEqual(["run-y"]); + + // The in-flight refresh resolves and overwrites the store with the + // stale full list, resurrecting run-x — this is the race being guarded + // against, reproduced here explicitly. + await act(async () => { + await result.current.refresh(); + }); + rerender(); + expect(result.current.runs.map((r) => r.id).sort()).toEqual(["run-x", "run-y"]); + + // The delete now confirms server-side. Without re-applying the removal + // on success, run-x would stay resurrected until the next full refresh. + await act(async () => { + del.resolve({ deleted: true }); + await del.promise; + }); + rerender(); + expect(result.current.runs.map((r) => r.id)).toEqual(["run-y"]); + }); + + it("re-applies the optimistic clear if an in-flight refresh resurrects rows before delete-all confirms", async () => { + const runX = mkRun("run-x"); + const runY = mkRun("run-y"); + setRuns(AUTOMATION_ID, [runX, runY]); + + const del = deferred<{ deleted: boolean }>(); + vi.mocked(deleteAllAutomationRuns).mockReturnValue(del.promise); + vi.mocked(listAutomationRuns) + .mockReturnValueOnce(Promise.withResolvers().promise) + .mockResolvedValue([runX, runY]); + + const { result, rerender } = renderHook(() => useAutomationRuns(AUTOMATION_ID, WORKSPACE_ID)); + + act(() => { + result.current.deleteAllRuns(); + }); + rerender(); + expect(result.current.runs).toEqual([]); + + await act(async () => { + await result.current.refresh(); + }); + rerender(); + expect(result.current.runs).toHaveLength(2); + + await act(async () => { + del.resolve({ deleted: true }); + await del.promise; + }); + rerender(); + expect(result.current.runs).toEqual([]); + }); + + it("passes workspaceId through to the delete-run and delete-all-runs API calls", () => { + setRuns(AUTOMATION_ID, [mkRun("run-x")]); + vi.mocked(listAutomationRuns).mockReturnValue(Promise.withResolvers().promise); + vi.mocked(deleteAutomationRun).mockResolvedValue({ deleted: true }); + vi.mocked(deleteAllAutomationRuns).mockResolvedValue({ deleted: true }); + + const { result } = renderHook(() => useAutomationRuns(AUTOMATION_ID, WORKSPACE_ID)); + + act(() => { + result.current.deleteRun("run-x"); + }); + expect(deleteAutomationRun).toHaveBeenCalledWith("run-x", WORKSPACE_ID); + + act(() => { + result.current.deleteAllRuns(); + }); + expect(deleteAllAutomationRuns).toHaveBeenCalledWith(AUTOMATION_ID, WORKSPACE_ID); + }); +}); + +describe("useAutomationRuns - double-failure recovery", () => { + it("restores the specific deleted run if both the delete and the recovery refresh fail", async () => { + const runX = mkRun("run-x"); + const runY = mkRun("run-y"); + setRuns(AUTOMATION_ID, [runX, runY]); + + // Mount-effect fetch resolves immediately with the initial list so it + // doesn't interfere with the delete-triggered recovery fetch below. + vi.mocked(listAutomationRuns) + .mockResolvedValueOnce([runX, runY]) + .mockRejectedValueOnce(new Error("network down")); + vi.mocked(deleteAutomationRun).mockRejectedValue(new Error("delete failed")); + + const { result, rerender } = renderHook(() => useAutomationRuns(AUTOMATION_ID, WORKSPACE_ID)); + await act(async () => {}); + rerender(); + + await act(async () => { + result.current.deleteRun("run-x"); + // Flush the delete rejection and the subsequent revert-fetch rejection. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + rerender(); + + // Both the delete and the revert fetch failed — the store must not be + // left permanently missing run-x. It should be restored from the + // pre-delete snapshot rather than silently staying gone. + expect(result.current.runs.map((r) => r.id).sort()).toEqual(["run-x", "run-y"]); + }); + + it("restores the full pre-clear snapshot if both delete-all and the recovery refresh fail", async () => { + const runX = mkRun("run-x"); + const runY = mkRun("run-y"); + setRuns(AUTOMATION_ID, [runX, runY]); + + vi.mocked(listAutomationRuns) + .mockResolvedValueOnce([runX, runY]) + .mockRejectedValueOnce(new Error("network down")); + vi.mocked(deleteAllAutomationRuns).mockRejectedValue(new Error("delete-all failed")); + + const { result, rerender } = renderHook(() => useAutomationRuns(AUTOMATION_ID, WORKSPACE_ID)); + await act(async () => {}); + rerender(); + + await act(async () => { + result.current.deleteAllRuns(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + rerender(); + + // Both delete-all and the revert fetch failed — the store must not be + // left permanently empty. It should be restored from the pre-clear + // snapshot rather than silently staying cleared. + expect(result.current.runs.map((r) => r.id).sort()).toEqual(["run-x", "run-y"]); + }); +}); + +describe("useAutomationRuns - single-failure revert", () => { + it("shows a toast and reverts to the server list when deleteRun fails but the recovery refresh succeeds", async () => { + const runX = mkRun("run-x"); + const runY = mkRun("run-y"); + setRuns(AUTOMATION_ID, [runX, runY]); + + // Mount-effect fetch, then the delete-triggered recovery fetch, both + // succeed with the server's authoritative (unchanged) list. + vi.mocked(listAutomationRuns).mockResolvedValue([runX, runY]); + vi.mocked(deleteAutomationRun).mockRejectedValue(new Error("delete failed")); + + const { result, rerender } = renderHook(() => useAutomationRuns(AUTOMATION_ID, WORKSPACE_ID)); + await act(async () => {}); + rerender(); + + await act(async () => { + result.current.deleteRun("run-x"); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + rerender(); + + expect(toast.error).toHaveBeenCalledWith("delete failed"); + // The recovery refresh succeeded, so the store reflects the server's + // authoritative list rather than the double-failure local-cache fallback. + expect(result.current.runs.map((r) => r.id).sort()).toEqual(["run-x", "run-y"]); + }); + + it("shows a toast and reverts to the server list when deleteAllRuns fails but the recovery refresh succeeds", async () => { + const runX = mkRun("run-x"); + const runY = mkRun("run-y"); + setRuns(AUTOMATION_ID, [runX, runY]); + + vi.mocked(listAutomationRuns).mockResolvedValue([runX, runY]); + vi.mocked(deleteAllAutomationRuns).mockRejectedValue(new Error("delete-all failed")); + + const { result, rerender } = renderHook(() => useAutomationRuns(AUTOMATION_ID, WORKSPACE_ID)); + await act(async () => {}); + rerender(); + + await act(async () => { + result.current.deleteAllRuns(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + rerender(); + + expect(toast.error).toHaveBeenCalledWith("delete-all failed"); + expect(result.current.runs.map((r) => r.id).sort()).toEqual(["run-x", "run-y"]); + }); +}); diff --git a/apps/web/hooks/domains/settings/use-automation-runs.ts b/apps/web/hooks/domains/settings/use-automation-runs.ts index e8b4b68d48..99222a4a15 100644 --- a/apps/web/hooks/domains/settings/use-automation-runs.ts +++ b/apps/web/hooks/domains/settings/use-automation-runs.ts @@ -1,13 +1,18 @@ "use client"; import { useEffect, useCallback } from "react"; -import { listAutomationRuns } from "@/lib/api/domains/automation-api"; -import { useAppStore } from "@/components/state-provider"; +import { toast } from "sonner"; +import { + listAutomationRuns, + deleteAutomationRun, + deleteAllAutomationRuns, +} from "@/lib/api/domains/automation-api"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import type { AutomationRun } from "@/lib/types/automation"; const EMPTY_RUNS: AutomationRun[] = []; -export function useAutomationRuns(automationId: string | null) { +export function useAutomationRuns(automationId: string | null, workspaceId: string) { const runs = useAppStore((state) => automationId ? (state.automationRuns.byAutomationId[automationId] ?? EMPTY_RUNS) : EMPTY_RUNS, ); @@ -16,6 +21,10 @@ export function useAutomationRuns(automationId: string | null) { ); const setRuns = useAppStore((state) => state.setAutomationRuns); const setRunsLoading = useAppStore((state) => state.setAutomationRunsLoading); + const removeRun = useAppStore((state) => state.removeAutomationRun); + const clearRuns = useAppStore((state) => state.clearAutomationRuns); + const restoreRun = useAppStore((state) => state.restoreAutomationRun); + const storeApi = useAppStoreApi(); useEffect(() => { if (!automationId || loading) return; @@ -46,5 +55,73 @@ export function useAutomationRuns(automationId: string | null) { }); }, [automationId, setRuns, setRunsLoading]); - return { runs, loading, refresh }; + const deleteRun = useCallback( + (runId: string) => { + if (!automationId) return; + // Snapshot the run being removed so we can restore it precisely (not + // the whole list, which could clobber unrelated concurrent changes) + // if both the delete and the recovery refresh below fail. + const deletedRun = storeApi + .getState() + .automationRuns.byAutomationId[automationId]?.find((r) => r.id === runId); + removeRun(automationId, runId); // optimistic + deleteAutomationRun(runId, workspaceId) + .then(() => { + // Re-apply the removal: an in-flight refresh() / initial load can + // resolve between the optimistic removeRun above and this success + // callback and overwrite the store with the pre-delete list, + // resurrecting the row. Removing it again here is a no-op unless + // that happened. + removeRun(automationId, runId); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "Failed to delete run"; + toast.error(msg); + // revert on failure + listAutomationRuns(automationId) + .then((result) => setRuns(automationId, result ?? [])) + .catch(() => { + // The recovery refresh also failed — the store would + // otherwise stay permanently missing this row even though + // the delete never succeeded server-side. Fall back to + // re-inserting just the run we know we removed. + if (deletedRun) { + restoreRun(automationId, deletedRun); + } + toast.error("Could not refresh runs — restored from local cache"); + }); + }); + }, + [automationId, removeRun, restoreRun, setRuns, storeApi, workspaceId], + ); + + const deleteAllRuns = useCallback(() => { + if (!automationId) return; + // Snapshot the full list so we can restore it if both the delete-all + // and the recovery refresh below fail. + const previousRuns = storeApi.getState().automationRuns.byAutomationId[automationId] ?? []; + clearRuns(automationId); // optimistic + deleteAllAutomationRuns(automationId, workspaceId) + .then(() => { + // See deleteRun: guard against an in-flight refresh() resurrecting + // rows between the optimistic clear and this success callback. + clearRuns(automationId); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "Failed to delete runs"; + toast.error(msg); + // revert on failure + listAutomationRuns(automationId) + .then((result) => setRuns(automationId, result ?? [])) + .catch(() => { + // The recovery refresh also failed — without this the store + // would stay permanently empty even though delete-all never + // succeeded server-side. Fall back to the pre-clear snapshot. + setRuns(automationId, previousRuns); + toast.error("Could not refresh runs — restored from local cache"); + }); + }); + }, [automationId, clearRuns, setRuns, storeApi, workspaceId]); + + return { runs, loading, refresh, deleteRun, deleteAllRuns }; } diff --git a/apps/web/lib/api/domains/automation-api.ts b/apps/web/lib/api/domains/automation-api.ts index 6fc6400ede..d4ea1c9876 100644 --- a/apps/web/lib/api/domains/automation-api.ts +++ b/apps/web/lib/api/domains/automation-api.ts @@ -95,3 +95,23 @@ export async function deleteTrigger(id: string): Promise<{ deleted: boolean }> { export async function listTriggerTypes(): Promise { return requireClient().request("automation.trigger_types", {}); } + +export async function deleteAutomationRun( + runId: string, + workspaceId: string, +): Promise<{ deleted: boolean }> { + return requireClient().request<{ deleted: boolean }>("automation.run.delete", { + run_id: runId, + workspace_id: workspaceId, + }); +} + +export async function deleteAllAutomationRuns( + automationId: string, + workspaceId: string, +): Promise<{ deleted: boolean }> { + return requireClient().request<{ deleted: boolean }>("automation.runs.delete_all", { + automation_id: automationId, + workspace_id: workspaceId, + }); +} diff --git a/apps/web/lib/state/slices/automations/automations-slice.ts b/apps/web/lib/state/slices/automations/automations-slice.ts index 5fbcb2905d..37e578664d 100644 --- a/apps/web/lib/state/slices/automations/automations-slice.ts +++ b/apps/web/lib/state/slices/automations/automations-slice.ts @@ -50,7 +50,14 @@ function createAutomationsActions( function createRunsActions( set: ImmerSet, -): Pick { +): Pick< + AutomationsSlice, + | "setAutomationRuns" + | "setAutomationRunsLoading" + | "removeAutomationRun" + | "clearAutomationRuns" + | "restoreAutomationRun" +> { return { setAutomationRuns: (automationId, runs) => set((draft) => { @@ -60,6 +67,35 @@ function createRunsActions( set((draft) => { draft.automationRuns.loading[automationId] = loading; }), + removeAutomationRun: (automationId, runId) => + set((draft) => { + const runs = draft.automationRuns.byAutomationId[automationId]; + if (runs) { + draft.automationRuns.byAutomationId[automationId] = runs.filter((r) => r.id !== runId); + } + }), + clearAutomationRuns: (automationId) => + set((draft) => { + draft.automationRuns.byAutomationId[automationId] = []; + }), + // Re-inserts a single run that was optimistically removed but whose + // deletion could not be confirmed AND whose recovery refresh also + // failed — the last-resort fallback so the UI never silently drifts + // from a known state. Idempotent (no-op if already present) and keeps + // the list sorted by created_at desc, matching the server's ordering. + restoreAutomationRun: (automationId, run) => + set((draft) => { + const runs = draft.automationRuns.byAutomationId[automationId] ?? []; + if (runs.some((r) => r.id === run.id)) return; + const insertAt = runs.findIndex((r) => r.created_at < run.created_at); + const next = runs.slice(); + if (insertAt === -1) { + next.push(run); + } else { + next.splice(insertAt, 0, run); + } + draft.automationRuns.byAutomationId[automationId] = next; + }), }; } diff --git a/apps/web/lib/state/slices/automations/types.ts b/apps/web/lib/state/slices/automations/types.ts index c76647f411..737a9e6f48 100644 --- a/apps/web/lib/state/slices/automations/types.ts +++ b/apps/web/lib/state/slices/automations/types.ts @@ -24,6 +24,9 @@ export type AutomationsSliceActions = { removeAutomation: (id: string) => void; setAutomationRuns: (automationId: string, runs: AutomationRun[]) => void; setAutomationRunsLoading: (automationId: string, loading: boolean) => void; + removeAutomationRun: (automationId: string, runId: string) => void; + clearAutomationRuns: (automationId: string) => void; + restoreAutomationRun: (automationId: string, run: AutomationRun) => void; }; export type AutomationsSlice = AutomationsSliceState & AutomationsSliceActions;