feat(automation): delete buttons for individual and all runs in Recent Runs (#1586)
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user