feat(automation): delete buttons for individual and all runs in Recent Runs (#1586)

This commit is contained in:
Clément Fiere
2026-07-06 17:39:33 +02:00
committed by GitHub
parent 90de507fed
commit 26773b28d1
20 changed files with 1684 additions and 74 deletions
+5
View File
@@ -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/
@@ -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})
}
}
@@ -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))
}
}
+105 -5
View File
@@ -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",
@@ -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
}
+37
View File
@@ -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) {
@@ -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))
}
}
@@ -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})
}
}
@@ -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,
@@ -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
+2
View File
@@ -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
@@ -353,23 +353,7 @@ export function AutomationEditor({ workspaceId, automationId }: AutomationEditor
return (
<div className="max-w-3xl space-y-6" data-testid="automation-editor">
<div className="space-y-2">
<RequiredFieldLabel htmlFor="automation-name">Name</RequiredFieldLabel>
<Input
id="automation-name"
data-testid="automation-name-input"
value={form.name}
onChange={(e) => 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() && (
<p id="automation-name-help" className="text-xs text-muted-foreground">
Enter an automation name to enable saving.
</p>
)}
</div>
<NameField value={form.name} onChange={(v) => updateField("name", v)} />
<Separator />
<WhenSection
triggerActions={triggerActions}
@@ -389,7 +373,11 @@ export function AutomationEditor({ workspaceId, automationId }: AutomationEditor
<Separator />
<SettingsSection form={form} updateField={updateField} />
<Separator />
<RunsSection automationId={currentId} executionMode={form.executionMode} />
<RunsSection
automationId={currentId}
executionMode={form.executionMode}
workspaceId={workspaceId}
/>
<EditorFooter
canSave={canSave}
saving={saving}
@@ -408,6 +396,28 @@ export function AutomationEditor({ workspaceId, automationId }: AutomationEditor
);
}
function NameField({ value, onChange }: { value: string; onChange: (value: string) => void }) {
return (
<div className="space-y-2">
<RequiredFieldLabel htmlFor="automation-name">Name</RequiredFieldLabel>
<Input
id="automation-name"
data-testid="automation-name-input"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Automation name"
aria-describedby={!value.trim() ? "automation-name-help" : undefined}
aria-invalid={!value.trim() ? true : undefined}
/>
{!value.trim() && (
<p id="automation-name-help" className="text-xs text-muted-foreground">
Enter an automation name to enable saving.
</p>
)}
</div>
);
}
function CreatedWebhookDialogHost({
details,
onClose,
+135 -46
View File
@@ -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 (
<TableRow
className={
rowClickable
? "group cursor-pointer hover:bg-muted/50"
: "group hover:bg-transparent focus-within:bg-transparent"
}
onClick={rowClickable ? () => onNavigate(run.task_id) : undefined}
>
<TableCell className="text-sm">{run.trigger_type}</TableCell>
<TableCell>
<Badge variant={badge.variant}>{badge.label}</Badge>
</TableCell>
<TableCell className="text-sm font-mono">
{run.task_id ? run.task_id.slice(0, 8) : "-"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatRelativeTime(run.created_at)}
</TableCell>
<TableCell className="text-sm text-destructive max-w-[200px] truncate">
{run.error_message || "-"}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon-sm"
className="cursor-pointer text-muted-foreground hover:text-destructive opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto [@media(hover:none)]:opacity-100 [@media(hover:none)]:pointer-events-auto"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onDelete(run.id);
}}
title="Delete run"
data-testid="delete-run"
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
);
}
type DeleteAllButtonProps = { disabled: boolean; onConfirm: () => void };
function DeleteAllButton({ disabled, onConfirm }: DeleteAllButtonProps) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="cursor-pointer text-destructive hover:text-destructive"
disabled={disabled}
title="Delete all runs"
data-testid="delete-all-runs"
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete all runs?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove all run records for this automation — including any not
currently loaded — and their associated tasks. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={onConfirm}
data-testid="delete-all-runs-confirm"
>
Delete all
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
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) {
)}
</button>
{expanded && (
<Button
variant="ghost"
size="icon-sm"
className="cursor-pointer"
onClick={refresh}
disabled={loading}
>
<IconRefresh className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
</Button>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
className="cursor-pointer"
onClick={refresh}
disabled={loading}
title="Refresh"
>
<IconRefresh className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
</Button>
{runs.length > 0 && <DeleteAllButton disabled={loading} onConfirm={deleteAllRuns} />}
</div>
)}
</div>
{expanded && (
@@ -73,47 +183,26 @@ export function RunsSection({ automationId, executionMode }: RunsSectionProps) {
<TableHead>Task</TableHead>
<TableHead>Time</TableHead>
<TableHead>Error</TableHead>
<TableHead className="w-8" />
</TableRow>
</TableHeader>
<TableBody>
{runs.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-4">
<TableCell colSpan={6} className="text-center text-muted-foreground py-4">
{loading ? "Loading..." : "No runs yet"}
</TableCell>
</TableRow>
) : (
runs.map((run) => {
const badge = STATUS_BADGE[run.status] ?? STATUS_BADGE.triggered;
const rowClickable = taskClickable && !!run.task_id;
return (
<TableRow
key={run.id}
className={
rowClickable
? "cursor-pointer hover:bg-muted/50"
: "hover:bg-transparent focus-within:bg-transparent"
}
onClick={
rowClickable ? () => router.push(`/tasks/${run.task_id}`) : undefined
}
>
<TableCell className="text-sm">{run.trigger_type}</TableCell>
<TableCell>
<Badge variant={badge.variant}>{badge.label}</Badge>
</TableCell>
<TableCell className="text-sm font-mono">
{run.task_id ? run.task_id.slice(0, 8) : "-"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatRelativeTime(run.created_at)}
</TableCell>
<TableCell className="text-sm text-destructive max-w-[200px] truncate">
{run.error_message || "-"}
</TableCell>
</TableRow>
);
})
runs.map((run) => (
<RunRow
key={run.id}
run={run}
taskClickable={executionMode !== "run"}
onDelete={deleteRun}
onNavigate={(id) => router.push(`/tasks/${id}`)}
/>
))
)}
</TableBody>
</Table>
+32
View File
@@ -1885,6 +1885,38 @@ export class ApiClient {
async probeSSHShells(executorId: string): Promise<SSHProbeShellsResponse> {
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 ---
@@ -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 });
});
});
@@ -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<string, AutomationRun[]>;
loading: Record<string, boolean>;
};
};
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<T>() {
const { promise, resolve } = Promise.withResolvers<T>();
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<AutomationRun[]>().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<AutomationRun[]>().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<AutomationRun[]>().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"]);
});
});
@@ -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 };
}
@@ -95,3 +95,23 @@ export async function deleteTrigger(id: string): Promise<{ deleted: boolean }> {
export async function listTriggerTypes(): Promise<TriggerTypeInfo[]> {
return requireClient().request<TriggerTypeInfo[]>("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,
});
}
@@ -50,7 +50,14 @@ function createAutomationsActions(
function createRunsActions(
set: ImmerSet,
): Pick<AutomationsSlice, "setAutomationRuns" | "setAutomationRunsLoading"> {
): 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;
}),
};
}
@@ -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;