From 658bd349b523db86fcfd33cf04276fb0f4e15f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 16 Feb 2026 13:48:58 +0000 Subject: [PATCH] feat: improve workflows (#228) --- apps/backend/cmd/kandev/adapters.go | 5 + apps/backend/cmd/kandev/main.go | 8 +- apps/backend/cmd/kandev/orchestrator.go | 66 +- apps/backend/cmd/kandev/services.go | 55 +- apps/backend/internal/analytics/dto/dto.go | 2 +- .../analytics/handlers/stats_handlers.go | 2 +- .../internal/analytics/models/models.go | 2 +- .../analytics/repository/sqlite/repository.go | 3 - .../repository/sqlite/repository_test.go | 9 +- .../analytics/repository/sqlite/stats.go | 15 +- apps/backend/internal/events/types.go | 8 +- .../gateway/websocket/task_notifications.go | 6 +- .../internal/integration/integration_test.go | 182 ++-- .../internal/integration/orchestrator_test.go | 44 +- .../backend/internal/mcp/handlers/handlers.go | 42 +- .../internal/orchestrator/event_handlers.go | 565 +++++++----- .../orchestrator/event_handlers_test.go | 578 +++++++++++- .../orchestrator/executor/executor_test.go | 28 +- .../internal/orchestrator/queue/queue_test.go | 2 +- .../orchestrator/scheduler/scheduler_test.go | 2 +- apps/backend/internal/orchestrator/service.go | 22 +- .../internal/orchestrator/task_operations.go | 64 +- apps/backend/internal/sysprompt/sysprompt.go | 6 +- .../backend/internal/task/controller/board.go | 244 ----- apps/backend/internal/task/controller/task.go | 93 +- .../internal/task/controller/workflow.go | 256 ++++++ apps/backend/internal/task/dto/dto.go | 75 +- apps/backend/internal/task/dto/requests.go | 39 +- .../task/handlers/message_handlers.go | 12 + .../task/handlers/process_handlers_test.go | 22 +- .../internal/task/handlers/task_handlers.go | 115 ++- ...board_handlers.go => workflow_handlers.go} | 166 ++-- apps/backend/internal/task/models/models.go | 10 +- .../internal/task/models/models_test.go | 40 +- .../internal/task/repository/interface.go | 20 +- .../internal/task/repository/sqlite/base.go | 10 +- .../internal/task/repository/sqlite/board.go | 136 --- .../task/repository/sqlite/defaults.go | 18 +- .../internal/task/repository/sqlite/task.go | 50 +- .../task/repository/sqlite/workflow.go | 136 +++ .../internal/task/repository/sqlite_test.go | 236 ++--- apps/backend/internal/task/service/service.go | 312 ++++--- .../internal/task/service/service_test.go | 114 +-- .../internal/user/controller/controller.go | 3 +- apps/backend/internal/user/dto/dto.go | 9 +- apps/backend/internal/user/models/models.go | 3 +- apps/backend/internal/user/service/service.go | 13 +- apps/backend/internal/user/store/sqlite.go | 9 +- .../workflow/controller/controller.go | 95 +- .../internal/workflow/handlers/handlers.go | 36 +- .../internal/workflow/models/models.go | 172 +++- .../internal/workflow/repository/sqlite.go | 854 ++++++++++++------ .../internal/workflow/service/service.go | 187 ++-- .../internal/workflow/service/service_test.go | 308 +++++++ apps/backend/pkg/api/v1/task.go | 2 +- apps/backend/pkg/api/v1/user.go | 9 +- .../pkg/api/v1/{board.go => workflow.go} | 12 +- apps/backend/pkg/websocket/actions.go | 22 +- apps/packages/ui/src/dialog.tsx | 2 +- apps/packages/ui/src/pannel-session.tsx | 65 +- apps/web/app/actions/workspaces.ts | 175 ++-- apps/web/app/globals.css | 41 + apps/web/app/layout.tsx | 2 +- apps/web/app/page.tsx | 45 +- apps/web/app/s/[sessionId]/page.tsx | 27 +- .../web/app/settings/general/editors/page.tsx | 3 +- apps/web/app/settings/general/page.tsx | 3 +- .../settings/workspace/[id]/boards/page.tsx | 36 - .../workspace/[id]/workflows/page.tsx | 36 + .../workspace/workspace-boards-client.tsx | 300 ------ .../workspace/workspace-edit-client.tsx | 407 +++------ .../workspace/workspace-workflows-client.tsx | 329 +++++++ .../workspace/workspaces-page-client.tsx | 4 +- apps/web/app/tasks/columns.tsx | 18 +- apps/web/app/tasks/page.tsx | 18 +- apps/web/app/tasks/tasks-page-client.tsx | 36 +- .../components/diff/diff-viewer-resolver.tsx | 2 +- .../editors/monaco/monaco-code-editor.tsx | 1 - apps/web/components/kanban-board-grid.tsx | 90 +- apps/web/components/kanban-board.tsx | 110 +-- apps/web/components/kanban-card.tsx | 15 +- apps/web/components/kanban-column.tsx | 27 +- .../components/kanban-display-dropdown.tsx | 37 +- apps/web/components/kanban-with-preview.tsx | 2 +- .../components/kanban/graph2-connector.tsx | 28 + .../components/kanban/graph2-step-node.tsx | 211 +++++ .../kanban/graph2-task-pipeline.tsx | 168 ++++ apps/web/components/kanban/kanban-header.tsx | 49 +- .../components/kanban/mobile-column-tabs.tsx | 14 +- .../components/kanban/mobile-drop-targets.tsx | 30 +- .../components/kanban/mobile-menu-sheet.tsx | 22 +- .../components/kanban/swimlane-container.tsx | 166 ++++ .../kanban/swimlane-graph-content.tsx | 344 +++++++ .../kanban/swimlane-graph2-content.tsx | 72 ++ .../web/components/kanban/swimlane-header.tsx | 40 + .../kanban/swimlane-kanban-content.tsx | 208 +++++ .../components/kanban/swimlane-section.tsx | 33 + .../components/kanban/swipeable-columns.tsx | 22 +- apps/web/components/onboarding-dialog.tsx | 4 +- apps/web/components/settings/board-card.tsx | 274 ------ .../components/settings/editors-settings.tsx | 4 +- .../components/settings/general-settings.tsx | 1 - .../settings/settings-app-sidebar.tsx | 8 +- .../settings/shell-settings-card.tsx | 1 - .../web/components/settings/workflow-card.tsx | 600 ++++++++++++ .../settings/workflow-pipeline-editor.tsx | 846 +++++++++++++++++ .../settings/workflow-step-editor.tsx | 387 -------- apps/web/components/task-create-dialog.tsx | 138 ++- .../task/chat/chat-input-container.tsx | 6 +- .../task/chat/virtualized-message-list.tsx | 54 +- .../task/dockview-desktop-layout.tsx | 16 +- .../task/document/document-panel.tsx | 6 +- .../task/mobile/session-mobile-layout.tsx | 6 +- .../task/mobile/session-tablet-layout.tsx | 6 +- .../mobile/session-task-switcher-sheet.tsx | 119 +-- .../web/components/task/sessions-dropdown.tsx | 6 +- .../web/components/task/task-center-panel.tsx | 2 +- apps/web/components/task/task-item-menu.tsx | 2 +- apps/web/components/task/task-item.tsx | 83 +- apps/web/components/task/task-layout.tsx | 10 +- .../web/components/task/task-page-content.tsx | 27 +- .../components/task/task-session-sidebar.tsx | 229 ++--- apps/web/components/task/task-switcher.tsx | 158 +--- apps/web/components/task/task-top-bar.tsx | 11 + apps/web/components/task/workflow-stepper.tsx | 100 ++ ...ard-switcher.tsx => workflow-switcher.tsx} | 44 +- apps/web/components/workflow-selector-row.tsx | 100 ++ .../kanban/use-all-workflow-snapshots.ts | 83 ++ .../domains/kanban/use-kanban-actions.ts | 26 +- .../hooks/domains/kanban/use-kanban-data.ts | 28 +- .../domains/kanban/use-swimlane-collapse.ts | 39 + .../hooks/domains/kanban/use-swimlane-move.ts | 106 +++ .../web/hooks/domains/settings/use-editors.ts | 3 +- apps/web/hooks/use-boards.ts | 24 - apps/web/hooks/use-drag-and-drop.ts | 42 +- apps/web/hooks/use-kanban-display-settings.ts | 77 +- apps/web/hooks/use-session-layout-state.ts | 2 +- apps/web/hooks/use-task-actions.ts | 2 +- apps/web/hooks/use-task-crud.ts | 6 +- apps/web/hooks/use-tasks.ts | 16 +- apps/web/hooks/use-user-display-settings.ts | 100 +- ...d-snapshot.ts => use-workflow-snapshot.ts} | 10 +- apps/web/hooks/use-workflows.ts | 25 + apps/web/lib/api/domains/kanban-api.ts | 22 +- apps/web/lib/api/domains/settings-api.ts | 3 +- apps/web/lib/api/domains/workflow-api.ts | 51 +- apps/web/lib/kanban/view-registry.ts | 63 ++ apps/web/lib/lsp/lsp-client-manager.ts | 2 +- apps/web/lib/ssr/mapper.ts | 8 +- apps/web/lib/state/hydration/hydrator.ts | 11 +- apps/web/lib/state/slices/index.ts | 2 +- .../lib/state/slices/kanban/kanban-slice.ts | 48 +- apps/web/lib/state/slices/kanban/types.ts | 32 +- .../state/slices/settings/settings-slice.ts | 3 +- apps/web/lib/state/slices/settings/types.ts | 3 +- apps/web/lib/state/store.ts | 27 +- apps/web/lib/types/backend.ts | 44 +- apps/web/lib/types/http.ts | 96 +- apps/web/lib/utils/needs-action.ts | 16 + apps/web/lib/ws/handlers/kanban.ts | 65 +- apps/web/lib/ws/handlers/tasks.ts | 133 ++- apps/web/lib/ws/handlers/users.ts | 4 +- .../ws/handlers/{boards.ts => workflows.ts} | 32 +- apps/web/lib/ws/handlers/workspaces.ts | 4 +- apps/web/lib/ws/router.ts | 4 +- package-lock.json | 6 + 166 files changed, 8715 insertions(+), 4593 deletions(-) create mode 100644 apps/backend/internal/task/controller/workflow.go rename apps/backend/internal/task/handlers/{board_handlers.go => workflow_handlers.go} (52%) delete mode 100644 apps/backend/internal/task/repository/sqlite/board.go create mode 100644 apps/backend/internal/task/repository/sqlite/workflow.go create mode 100644 apps/backend/internal/workflow/service/service_test.go rename apps/backend/pkg/api/v1/{board.go => workflow.go} (69%) delete mode 100644 apps/web/app/settings/workspace/[id]/boards/page.tsx create mode 100644 apps/web/app/settings/workspace/[id]/workflows/page.tsx delete mode 100644 apps/web/app/settings/workspace/workspace-boards-client.tsx create mode 100644 apps/web/app/settings/workspace/workspace-workflows-client.tsx create mode 100644 apps/web/components/kanban/graph2-connector.tsx create mode 100644 apps/web/components/kanban/graph2-step-node.tsx create mode 100644 apps/web/components/kanban/graph2-task-pipeline.tsx create mode 100644 apps/web/components/kanban/swimlane-container.tsx create mode 100644 apps/web/components/kanban/swimlane-graph-content.tsx create mode 100644 apps/web/components/kanban/swimlane-graph2-content.tsx create mode 100644 apps/web/components/kanban/swimlane-header.tsx create mode 100644 apps/web/components/kanban/swimlane-kanban-content.tsx create mode 100644 apps/web/components/kanban/swimlane-section.tsx delete mode 100644 apps/web/components/settings/board-card.tsx create mode 100644 apps/web/components/settings/workflow-card.tsx create mode 100644 apps/web/components/settings/workflow-pipeline-editor.tsx delete mode 100644 apps/web/components/settings/workflow-step-editor.tsx create mode 100644 apps/web/components/task/workflow-stepper.tsx rename apps/web/components/task/{board-switcher.tsx => workflow-switcher.tsx} (64%) create mode 100644 apps/web/components/workflow-selector-row.tsx create mode 100644 apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts create mode 100644 apps/web/hooks/domains/kanban/use-swimlane-collapse.ts create mode 100644 apps/web/hooks/domains/kanban/use-swimlane-move.ts delete mode 100644 apps/web/hooks/use-boards.ts rename apps/web/hooks/{use-board-snapshot.ts => use-workflow-snapshot.ts} (66%) create mode 100644 apps/web/hooks/use-workflows.ts create mode 100644 apps/web/lib/kanban/view-registry.ts create mode 100644 apps/web/lib/utils/needs-action.ts rename apps/web/lib/ws/handlers/{boards.ts => workflows.ts} (55%) create mode 100644 package-lock.json diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index 24d3960796..71d49966db 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -243,6 +243,11 @@ func (w *orchestratorWrapper) StartCreatedSession(ctx context.Context, taskID, s return err } +// ProcessOnTurnStart forwards to the orchestrator service. +func (w *orchestratorWrapper) ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error { + return w.svc.ProcessOnTurnStart(ctx, taskID, sessionID) +} + // messageCreatorAdapter adapts the task service to the orchestrator.MessageCreator interface type messageCreatorAdapter struct { svc *taskservice.Service diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 95ff65f7d2..aca00c2109 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -507,8 +507,8 @@ func main() { router.Use(corsMiddleware()) workspaceController := taskcontroller.NewWorkspaceController(taskSvc) - boardController := taskcontroller.NewBoardController(taskSvc) - boardController.SetWorkflowStepLister(services.Workflow) + taskWorkflowController := taskcontroller.NewWorkflowController(taskSvc) + taskWorkflowController.SetWorkflowStepLister(services.Workflow) taskController := taskcontroller.NewTaskController(taskSvc) messageController := taskcontroller.NewMessageController(taskSvc) repositoryController := taskcontroller.NewRepositoryController(taskSvc) @@ -521,7 +521,7 @@ func main() { // Task Service handlers (HTTP + WebSocket) taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) - taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log) + taskhandlers.RegisterWorkflowRoutes(router, gateway.Dispatcher, taskWorkflowController, log) planService := taskservice.NewPlanService(taskRepo, eventBus, log) taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, orchestratorSvc, taskRepo, planService, log) taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log) @@ -566,7 +566,7 @@ func main() { // MCP handlers for agentctl WS tunnel (agentctl -> backend) mcpHandlers := mcphandlers.NewHandlers( workspaceController, - boardController, + taskWorkflowController, taskController, workflowCtrl, clarificationStore, diff --git a/apps/backend/cmd/kandev/orchestrator.go b/apps/backend/cmd/kandev/orchestrator.go index a152cbe82f..794dac3718 100644 --- a/apps/backend/cmd/kandev/orchestrator.go +++ b/apps/backend/cmd/kandev/orchestrator.go @@ -12,6 +12,7 @@ import ( "github.com/kandev/kandev/internal/task/repository" taskservice "github.com/kandev/kandev/internal/task/service" userservice "github.com/kandev/kandev/internal/user/service" + wfmodels "github.com/kandev/kandev/internal/workflow/models" workflowservice "github.com/kandev/kandev/internal/workflow/service" "github.com/kandev/kandev/internal/worktree" ) @@ -57,65 +58,22 @@ func provideOrchestrator( } // orchestratorWorkflowStepGetterAdapter adapts workflow service to orchestrator's WorkflowStepGetter interface. +// Since orchestrator now uses wfmodels.WorkflowStep directly, the adapter simply delegates to the service. type orchestratorWorkflowStepGetterAdapter struct { svc *workflowservice.Service } // GetStep implements orchestrator.WorkflowStepGetter. -func (a *orchestratorWorkflowStepGetterAdapter) GetStep(ctx context.Context, stepID string) (*orchestrator.WorkflowStep, error) { - step, err := a.svc.GetStep(ctx, stepID) - if err != nil { - return nil, err - } - onCompleteStepID := "" - if step.OnCompleteStepID != nil { - onCompleteStepID = *step.OnCompleteStepID - } - onApprovalStepID := "" - if step.OnApprovalStepID != nil { - onApprovalStepID = *step.OnApprovalStepID - } - return &orchestrator.WorkflowStep{ - ID: step.ID, - Name: step.Name, - StepType: string(step.StepType), - AutoStartAgent: step.AutoStartAgent, - PlanMode: step.PlanMode, - RequireApproval: step.RequireApproval, - PromptPrefix: step.PromptPrefix, - PromptSuffix: step.PromptSuffix, - OnCompleteStepID: onCompleteStepID, - OnApprovalStepID: onApprovalStepID, - }, nil +func (a *orchestratorWorkflowStepGetterAdapter) GetStep(ctx context.Context, stepID string) (*wfmodels.WorkflowStep, error) { + return a.svc.GetStep(ctx, stepID) } -// GetSourceStep implements orchestrator.WorkflowStepGetter. -func (a *orchestratorWorkflowStepGetterAdapter) GetSourceStep(ctx context.Context, boardID, targetStepID string) (*orchestrator.WorkflowStep, error) { - step, err := a.svc.GetSourceStep(ctx, boardID, targetStepID) - if err != nil { - return nil, err - } - if step == nil { - return nil, nil - } - onCompleteStepID := "" - if step.OnCompleteStepID != nil { - onCompleteStepID = *step.OnCompleteStepID - } - onApprovalStepID := "" - if step.OnApprovalStepID != nil { - onApprovalStepID = *step.OnApprovalStepID - } - return &orchestrator.WorkflowStep{ - ID: step.ID, - Name: step.Name, - StepType: string(step.StepType), - AutoStartAgent: step.AutoStartAgent, - PlanMode: step.PlanMode, - RequireApproval: step.RequireApproval, - PromptPrefix: step.PromptPrefix, - PromptSuffix: step.PromptSuffix, - OnCompleteStepID: onCompleteStepID, - OnApprovalStepID: onApprovalStepID, - }, nil +// GetNextStepByPosition implements orchestrator.WorkflowStepGetter. +func (a *orchestratorWorkflowStepGetterAdapter) GetNextStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) { + return a.svc.GetNextStepByPosition(ctx, workflowID, currentPosition) +} + +// GetPreviousStepByPosition implements orchestrator.WorkflowStepGetter. +func (a *orchestratorWorkflowStepGetterAdapter) GetPreviousStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) { + return a.svc.GetPreviousStepByPosition(ctx, workflowID, currentPosition) } diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index e6e6a12667..94328999b6 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -13,7 +13,7 @@ import ( promptservice "github.com/kandev/kandev/internal/prompts/service" taskservice "github.com/kandev/kandev/internal/task/service" userservice "github.com/kandev/kandev/internal/user/service" - workflowmodels "github.com/kandev/kandev/internal/workflow/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" workflowservice "github.com/kandev/kandev/internal/workflow/service" ) @@ -48,6 +48,9 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories // Wire workflow step getter to task service for MoveTask taskSvc.SetWorkflowStepGetter(&workflowStepGetterAdapter{svc: workflowSvc}) + // Wire start step resolver to task service for CreateTask + taskSvc.SetStartStepResolver(&startStepResolverAdapter{svc: workflowSvc}) + return &Services{ Task: taskSvc, User: userSvc, @@ -60,47 +63,31 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories } // workflowStepGetterAdapter adapts workflow service to task service's WorkflowStepGetter interface. +// Since task service now uses wfmodels.WorkflowStep directly, the adapter simply delegates to the service. type workflowStepGetterAdapter struct { svc *workflowservice.Service } // GetStep implements taskservice.WorkflowStepGetter. -func (a *workflowStepGetterAdapter) GetStep(ctx context.Context, stepID string) (*taskservice.WorkflowStep, error) { - step, err := a.svc.GetStep(ctx, stepID) - if err != nil { - return nil, err - } - return a.toTaskServiceStep(step), nil +func (a *workflowStepGetterAdapter) GetStep(ctx context.Context, stepID string) (*wfmodels.WorkflowStep, error) { + return a.svc.GetStep(ctx, stepID) } // GetNextStepByPosition implements taskservice.WorkflowStepGetter. -func (a *workflowStepGetterAdapter) GetNextStepByPosition(ctx context.Context, boardID string, currentPosition int) (*taskservice.WorkflowStep, error) { - step, err := a.svc.GetNextStepByPosition(ctx, boardID, currentPosition) - if err != nil { - return nil, err - } - if step == nil { - return nil, nil - } - return a.toTaskServiceStep(step), nil +func (a *workflowStepGetterAdapter) GetNextStepByPosition(ctx context.Context, boardID string, currentPosition int) (*wfmodels.WorkflowStep, error) { + return a.svc.GetNextStepByPosition(ctx, boardID, currentPosition) } -// toTaskServiceStep converts a workflow step to a task service step. -func (a *workflowStepGetterAdapter) toTaskServiceStep(step *workflowmodels.WorkflowStep) *taskservice.WorkflowStep { - return &taskservice.WorkflowStep{ - ID: step.ID, - BoardID: step.BoardID, - Name: step.Name, - StepType: string(step.StepType), - Position: step.Position, - Color: step.Color, - AutoStartAgent: step.AutoStartAgent, - PlanMode: step.PlanMode, - RequireApproval: step.RequireApproval, - PromptPrefix: step.PromptPrefix, - PromptSuffix: step.PromptSuffix, - AllowManualMove: step.AllowManualMove, - OnCompleteStepID: step.OnCompleteStepID, - OnApprovalStepID: step.OnApprovalStepID, - } +// startStepResolverAdapter adapts workflow service to task service's StartStepResolver interface. +type startStepResolverAdapter struct { + svc *workflowservice.Service +} + +// ResolveStartStep implements taskservice.StartStepResolver. +func (a *startStepResolverAdapter) ResolveStartStep(ctx context.Context, workflowID string) (string, error) { + step, err := a.svc.ResolveStartStep(ctx, workflowID) + if err != nil { + return "", err + } + return step.ID, nil } diff --git a/apps/backend/internal/analytics/dto/dto.go b/apps/backend/internal/analytics/dto/dto.go index 1e447438e2..c08d516d7e 100644 --- a/apps/backend/internal/analytics/dto/dto.go +++ b/apps/backend/internal/analytics/dto/dto.go @@ -5,7 +5,7 @@ type TaskStatsDTO struct { TaskID string `json:"task_id"` TaskTitle string `json:"task_title"` WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` + WorkflowID string `json:"workflow_id"` State string `json:"state"` SessionCount int `json:"session_count"` TurnCount int `json:"turn_count"` diff --git a/apps/backend/internal/analytics/handlers/stats_handlers.go b/apps/backend/internal/analytics/handlers/stats_handlers.go index 2ff7927bdc..d729c876ac 100644 --- a/apps/backend/internal/analytics/handlers/stats_handlers.go +++ b/apps/backend/internal/analytics/handlers/stats_handlers.go @@ -84,7 +84,7 @@ func (h *StatsHandlers) httpGetStats(c *gin.Context) { TaskID: ts.TaskID, TaskTitle: ts.TaskTitle, WorkspaceID: ts.WorkspaceID, - BoardID: ts.BoardID, + WorkflowID: ts.WorkflowID, State: ts.State, SessionCount: ts.SessionCount, TurnCount: ts.TurnCount, diff --git a/apps/backend/internal/analytics/models/models.go b/apps/backend/internal/analytics/models/models.go index be4d176f76..65cb13a96f 100644 --- a/apps/backend/internal/analytics/models/models.go +++ b/apps/backend/internal/analytics/models/models.go @@ -7,7 +7,7 @@ type TaskStats struct { TaskID string `json:"task_id"` TaskTitle string `json:"task_title"` WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` + WorkflowID string `json:"workflow_id"` State string `json:"state"` SessionCount int `json:"session_count"` TurnCount int `json:"turn_count"` diff --git a/apps/backend/internal/analytics/repository/sqlite/repository.go b/apps/backend/internal/analytics/repository/sqlite/repository.go index 748d1187be..f6b50f253a 100644 --- a/apps/backend/internal/analytics/repository/sqlite/repository.go +++ b/apps/backend/internal/analytics/repository/sqlite/repository.go @@ -80,9 +80,6 @@ func (r *Repository) ensureStatsIndexes() error { ON repositories(workspace_id) WHERE deleted_at IS NULL`, - // Workflow steps indexes - `CREATE INDEX IF NOT EXISTS idx_workflow_steps_type - ON workflow_steps(id, step_type)`, } for _, idx := range indexes { diff --git a/apps/backend/internal/analytics/repository/sqlite/repository_test.go b/apps/backend/internal/analytics/repository/sqlite/repository_test.go index 1d3c95cdab..7e536a66af 100644 --- a/apps/backend/internal/analytics/repository/sqlite/repository_test.go +++ b/apps/backend/internal/analytics/repository/sqlite/repository_test.go @@ -37,10 +37,13 @@ func createTestDB(t *testing.T) *sql.DB { ); CREATE TABLE IF NOT EXISTS workflow_steps ( id TEXT PRIMARY KEY, - board_id TEXT NOT NULL, + workflow_id TEXT NOT NULL, name TEXT NOT NULL, - step_type TEXT NOT NULL, position INTEGER NOT NULL, + color TEXT, + prompt TEXT, + events TEXT, + allow_manual_move INTEGER DEFAULT 1, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); @@ -244,7 +247,7 @@ func TestGetGlobalStats_WithTimeFilter(t *testing.T) { // Insert workspace + board + workflow step execOrFatal(t, dbConn, `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-1', 'Test', ?, ?)`, nowStr, nowStr) execOrFatal(t, dbConn, `INSERT INTO boards (id, workspace_id, name, created_at, updated_at) VALUES ('board-1', 'ws-1', 'Board', ?, ?)`, nowStr, nowStr) - execOrFatal(t, dbConn, `INSERT INTO workflow_steps (id, board_id, name, step_type, position, created_at, updated_at) VALUES ('step-todo', 'board-1', 'To Do', 'todo', 0, ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO workflow_steps (id, workflow_id, name, position, created_at, updated_at) VALUES ('step-todo', 'board-1', 'To Do', 0, ?, ?)`, nowStr, nowStr) // Insert a recent task and an old task execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, created_at, updated_at) VALUES ('task-recent', 'ws-1', 'board-1', 'step-todo', 'Recent', ?, ?)`, nowStr, nowStr) diff --git a/apps/backend/internal/analytics/repository/sqlite/stats.go b/apps/backend/internal/analytics/repository/sqlite/stats.go index 7467fdf537..869d432d8f 100644 --- a/apps/backend/internal/analytics/repository/sqlite/stats.go +++ b/apps/backend/internal/analytics/repository/sqlite/stats.go @@ -44,7 +44,7 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start t.id, t.title, t.workspace_id, - t.board_id, + t.workflow_id, t.state, COALESCE(session_stats.session_count, 0) as session_count, COALESCE(session_stats.turn_count, 0) as turn_count, @@ -109,7 +109,7 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start &stat.TaskID, &stat.TaskTitle, &stat.WorkspaceID, - &stat.BoardID, + &stat.WorkflowID, &stat.State, &stat.SessionCount, &stat.TurnCount, @@ -147,13 +147,15 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta } // Use separate subqueries to avoid row multiplication from JOINs - // Count tasks in "done" workflow step (step_type = 'done') as completed + // Count tasks in the last workflow step (highest position) as completed query := ` SELECT (SELECT COUNT(*) FROM tasks WHERE workspace_id = ? AND (? IS NULL OR created_at >= ?)) as total_tasks, (SELECT COUNT(*) FROM tasks t JOIN workflow_steps ws ON ws.id = t.workflow_step_id - WHERE t.workspace_id = ? AND ws.step_type = 'done' AND (? IS NULL OR t.created_at >= ?)) as completed_tasks, + WHERE t.workspace_id = ? + AND ws.position = (SELECT MAX(ws2.position) FROM workflow_steps ws2 WHERE ws2.workflow_id = ws.workflow_id) + AND (? IS NULL OR t.created_at >= ?)) as completed_tasks, (SELECT COUNT(*) FROM tasks WHERE workspace_id = ? AND state = 'IN_PROGRESS' AND (? IS NULL OR created_at >= ?)) as in_progress_tasks, (SELECT COUNT(*) FROM task_sessions s JOIN tasks t ON t.id = s.task_id WHERE t.workspace_id = ? AND (? IS NULL OR s.started_at >= ?)) as total_sessions, (SELECT COUNT(*) FROM task_session_turns turn @@ -303,7 +305,8 @@ func (r *Repository) GetCompletedTaskActivity(ctx context.Context, workspaceID s WHERE completed_at IS NOT NULL GROUP BY task_id ) ts ON ts.task_id = t.id - WHERE t.workspace_id = ? AND ws.step_type = 'done' + WHERE t.workspace_id = ? + AND ws.position = (SELECT MAX(ws2.position) FROM workflow_steps ws2 WHERE ws2.workflow_id = ws.workflow_id) GROUP BY date(ts.completed_at) ) activity ON activity.activity_date = d.date ORDER BY d.date ASC @@ -360,7 +363,7 @@ func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string, SELECT tr.repository_id, COUNT(DISTINCT t.id) as total_tasks, - COUNT(DISTINCT CASE WHEN ws.step_type = 'done' THEN t.id END) as completed_tasks, + COUNT(DISTINCT CASE WHEN ws.position = (SELECT MAX(ws2.position) FROM workflow_steps ws2 WHERE ws2.workflow_id = ws.workflow_id) THEN t.id END) as completed_tasks, COUNT(DISTINCT CASE WHEN t.state = 'IN_PROGRESS' THEN t.id END) as in_progress_tasks FROM task_repositories tr JOIN tasks t ON t.id = tr.task_id diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index cb52faa569..651222e522 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -16,11 +16,11 @@ const ( WorkspaceDeleted = "workspace.deleted" ) -// Event types for boards +// Event types for workflows const ( - BoardCreated = "board.created" - BoardUpdated = "board.updated" - BoardDeleted = "board.deleted" + WorkflowCreated = "workflow.created" + WorkflowUpdated = "workflow.updated" + WorkflowDeleted = "workflow.deleted" ) // Event types for comments diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go index 7f3ceec44b..339632559c 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications.go +++ b/apps/backend/internal/gateway/websocket/task_notifications.go @@ -28,9 +28,9 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub * b.subscribe(eventBus, events.WorkspaceCreated, ws.ActionWorkspaceCreated) b.subscribe(eventBus, events.WorkspaceUpdated, ws.ActionWorkspaceUpdated) b.subscribe(eventBus, events.WorkspaceDeleted, ws.ActionWorkspaceDeleted) - b.subscribe(eventBus, events.BoardCreated, ws.ActionBoardCreated) - b.subscribe(eventBus, events.BoardUpdated, ws.ActionBoardUpdated) - b.subscribe(eventBus, events.BoardDeleted, ws.ActionBoardDeleted) + b.subscribe(eventBus, events.WorkflowCreated, ws.ActionWorkflowCreated) + b.subscribe(eventBus, events.WorkflowUpdated, ws.ActionWorkflowUpdated) + b.subscribe(eventBus, events.WorkflowDeleted, ws.ActionWorkflowDeleted) b.subscribe(eventBus, events.TaskCreated, ws.ActionTaskCreated) b.subscribe(eventBus, events.TaskUpdated, ws.ActionTaskUpdated) b.subscribe(eventBus, events.TaskDeleted, ws.ActionTaskDeleted) diff --git a/apps/backend/internal/integration/integration_test.go b/apps/backend/internal/integration/integration_test.go index 9222c636cd..6e61539e68 100644 --- a/apps/backend/internal/integration/integration_test.go +++ b/apps/backend/internal/integration/integration_test.go @@ -103,15 +103,15 @@ func NewTestServer(t *testing.T) *TestServer { // Register handlers (HTTP + WS) workspaceController := taskcontroller.NewWorkspaceController(taskSvc) - boardController := taskcontroller.NewBoardController(taskSvc) - boardController.SetWorkflowStepLister(workflowSvc) + taskWorkflowController := taskcontroller.NewWorkflowController(taskSvc) + taskWorkflowController.SetWorkflowStepLister(workflowSvc) taskController := taskcontroller.NewTaskController(taskSvc) executorController := taskcontroller.NewExecutorController(taskSvc) environmentController := taskcontroller.NewEnvironmentController(taskSvc) repositoryController := taskcontroller.NewRepositoryController(taskSvc) workflowCtrl := workflowcontroller.NewController(workflowSvc) taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) - taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log) + taskhandlers.RegisterWorkflowRoutes(router, gateway.Dispatcher, taskWorkflowController, log) planService := taskservice.NewPlanService(taskRepo, eventBus, log) taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, nil, taskRepo, planService, log) taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log) @@ -339,10 +339,10 @@ func TestHealthCheck(t *testing.T) { } // ============================================ -// BOARD TESTS +// WORKFLOW TESTS // ============================================ -func TestBoardCRUD(t *testing.T) { +func TestWorkflowCRUD(t *testing.T) { ts := NewTestServer(t) defer ts.Close() @@ -351,12 +351,12 @@ func TestBoardCRUD(t *testing.T) { workspaceID := createWorkspace(t, client) - // Create board - t.Run("CreateBoard", func(t *testing.T) { - resp, err := client.SendRequest("board-create-1", ws.ActionBoardCreate, map[string]interface{}{ + // Create workflow + t.Run("CreateWorkflow", func(t *testing.T) { + resp, err := client.SendRequest("workflow-create-1", ws.ActionWorkflowCreate, map[string]interface{}{ "workspace_id": workspaceID, - "name": "Test Board", - "description": "A test board for integration testing", + "name": "Test Workflow", + "description": "A test workflow for integration testing", "workflow_template_id": "simple", }) require.NoError(t, err) @@ -367,12 +367,12 @@ func TestBoardCRUD(t *testing.T) { require.NoError(t, err) assert.NotEmpty(t, payload["id"]) - assert.Equal(t, "Test Board", payload["name"]) + assert.Equal(t, "Test Workflow", payload["name"]) }) - // List boards - t.Run("ListBoards", func(t *testing.T) { - resp, err := client.SendRequest("board-list-1", ws.ActionBoardList, map[string]interface{}{ + // List workflows + t.Run("ListWorkflows", func(t *testing.T) { + resp, err := client.SendRequest("workflow-list-1", ws.ActionWorkflowList, map[string]interface{}{ "workspace_id": workspaceID, }) require.NoError(t, err) @@ -381,9 +381,9 @@ func TestBoardCRUD(t *testing.T) { err = resp.ParsePayload(&payload) require.NoError(t, err) - boards, ok := payload["boards"].([]interface{}) + workflows, ok := payload["workflows"].([]interface{}) require.True(t, ok) - assert.Len(t, boards, 1) + assert.Len(t, workflows, 1) }) } @@ -400,23 +400,23 @@ func TestWorkflowStepList(t *testing.T) { workspaceID := createWorkspace(t, client) - // Create a board (workflow steps are created automatically with default template) - boardResp, err := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ + // Create a workflow (workflow steps are created automatically with default template) + workflowResp, err := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ "workspace_id": workspaceID, - "name": "Workflow Step Test Board", + "name": "Workflow Step Test Workflow", "workflow_template_id": "simple", }) require.NoError(t, err) - var boardPayload map[string]interface{} - err = boardResp.ParsePayload(&boardPayload) + var workflowPayload map[string]interface{} + err = workflowResp.ParsePayload(&workflowPayload) require.NoError(t, err) - boardID := boardPayload["id"].(string) + workflowID := workflowPayload["id"].(string) // List workflow steps t.Run("ListWorkflowSteps", func(t *testing.T) { resp, err := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "board_id": boardID, + "workflow_id": workflowID, }) require.NoError(t, err) @@ -450,22 +450,22 @@ func TestTaskCRUD(t *testing.T) { workspaceID := createWorkspace(t, client) repositoryID := createRepository(t, client, workspaceID) - // Create board (workflow steps are created automatically) - boardResp, err := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ + // Create workflow (workflow steps are created automatically) + workflowResp, err := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ "workspace_id": workspaceID, - "name": "Task Test Board", + "name": "Task Test Workflow", "workflow_template_id": "simple", }) require.NoError(t, err) - var boardPayload map[string]interface{} - err = boardResp.ParsePayload(&boardPayload) + var workflowPayload map[string]interface{} + err = workflowResp.ParsePayload(&workflowPayload) require.NoError(t, err) - boardID := boardPayload["id"].(string) + workflowID := workflowPayload["id"].(string) // Get first workflow step stepResp, err := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "board_id": boardID, + "workflow_id": workflowID, }) require.NoError(t, err) @@ -482,7 +482,7 @@ func TestTaskCRUD(t *testing.T) { t.Run("CreateTask", func(t *testing.T) { resp, err := client.SendRequest("task-create-1", ws.ActionTaskCreate, map[string]interface{}{ "workspace_id": workspaceID, - "board_id": boardID, + "workflow_id": workflowID, "workflow_step_id": workflowStepID, "title": "Test Task", "description": "A test task for integration testing", @@ -546,7 +546,7 @@ func TestTaskCRUD(t *testing.T) { // List tasks t.Run("ListTasks", func(t *testing.T) { resp, err := client.SendRequest("task-list-1", ws.ActionTaskList, map[string]interface{}{ - "board_id": boardID, + "workflow_id": workflowID, }) require.NoError(t, err) @@ -574,7 +574,7 @@ func TestTaskCRUD(t *testing.T) { // Verify task is deleted listResp, err := client.SendRequest("task-list-2", ws.ActionTaskList, map[string]interface{}{ - "board_id": boardID, + "workflow_id": workflowID, }) require.NoError(t, err) @@ -602,21 +602,21 @@ func TestTaskStateTransitions(t *testing.T) { workspaceID := createWorkspace(t, client) repositoryID := createRepository(t, client, workspaceID) - // Create board and task (workflow steps are created automatically) - boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ + // Create workflow and task (workflow steps are created automatically) + workflowResp, _ := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ "workspace_id": workspaceID, - "name": "State Test Board", + "name": "State Test Workflow", "workflow_template_id": "simple", }) - var boardPayload map[string]interface{} - if err := boardResp.ParsePayload(&boardPayload); err != nil { - t.Fatalf("failed to parse board payload: %v", err) + var workflowPayload map[string]interface{} + if err := workflowResp.ParsePayload(&workflowPayload); err != nil { + t.Fatalf("failed to parse workflow payload: %v", err) } - boardID := boardPayload["id"].(string) + workflowID := workflowPayload["id"].(string) // Get first workflow step stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "board_id": boardID, + "workflow_id": workflowID, }) var stepListPayload map[string]interface{} if err := stepResp.ParsePayload(&stepListPayload); err != nil { @@ -627,7 +627,7 @@ func TestTaskStateTransitions(t *testing.T) { taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ "workspace_id": workspaceID, - "board_id": boardID, + "workflow_id": workflowID, "workflow_step_id": workflowStepID, "title": "State Test Task", "description": "Test state transitions", @@ -687,21 +687,21 @@ func TestTaskMove(t *testing.T) { workspaceID := createWorkspace(t, client) repositoryID := createRepository(t, client, workspaceID) - // Create board (workflow steps are created automatically) - boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ + // Create workflow (workflow steps are created automatically) + workflowResp, _ := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ "workspace_id": workspaceID, - "name": "Move Test Board", + "name": "Move Test Workflow", "workflow_template_id": "simple", }) - var boardPayload map[string]interface{} - if err := boardResp.ParsePayload(&boardPayload); err != nil { - t.Fatalf("failed to parse board payload: %v", err) + var workflowPayload map[string]interface{} + if err := workflowResp.ParsePayload(&workflowPayload); err != nil { + t.Fatalf("failed to parse workflow payload: %v", err) } - boardID := boardPayload["id"].(string) + workflowID := workflowPayload["id"].(string) // Get workflow steps (at least 2 should exist from default template) stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "board_id": boardID, + "workflow_id": workflowID, }) var stepListPayload map[string]interface{} if err := stepResp.ParsePayload(&stepListPayload); err != nil { @@ -715,7 +715,7 @@ func TestTaskMove(t *testing.T) { // Create task in first step taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ "workspace_id": workspaceID, - "board_id": boardID, + "workflow_id": workflowID, "workflow_step_id": step1ID, "title": "Movable Task", "repository_id": repositoryID, @@ -731,7 +731,7 @@ func TestTaskMove(t *testing.T) { t.Run("MoveToStep2", func(t *testing.T) { resp, err := client.SendRequest("move-1", ws.ActionTaskMove, map[string]interface{}{ "id": taskID, - "board_id": boardID, + "workflow_id": workflowID, "workflow_step_id": step2ID, "position": 0, }) @@ -778,21 +778,21 @@ func TestMultipleClients(t *testing.T) { workspaceID := createWorkspace(t, client1) repositoryID := createRepository(t, client1, workspaceID) - // Client 1 creates a board - boardResp, err := client1.SendRequest("c1-board-1", ws.ActionBoardCreate, map[string]interface{}{ + // Client 1 creates a workflow + workflowResp, err := client1.SendRequest("c1-workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ "workspace_id": workspaceID, - "name": "Shared Board", + "name": "Shared Workflow", "workflow_template_id": "simple", }) require.NoError(t, err) - var boardPayload map[string]interface{} - err = boardResp.ParsePayload(&boardPayload) + var workflowPayload map[string]interface{} + err = workflowResp.ParsePayload(&workflowPayload) require.NoError(t, err) - boardID := boardPayload["id"].(string) + workflowID := workflowPayload["id"].(string) - // Client 2 can see the board (filter by workspace to avoid default board) - listResp, err := client2.SendRequest("c2-list-1", ws.ActionBoardList, map[string]interface{}{ + // Client 2 can see the workflow (filter by workspace to avoid default workflow) + listResp, err := client2.SendRequest("c2-list-1", ws.ActionWorkflowList, map[string]interface{}{ "workspace_id": workspaceID, }) require.NoError(t, err) @@ -801,13 +801,13 @@ func TestMultipleClients(t *testing.T) { err = listResp.ParsePayload(&listPayload) require.NoError(t, err) - boards, ok := listPayload["boards"].([]interface{}) + workflows, ok := listPayload["workflows"].([]interface{}) require.True(t, ok) - assert.Len(t, boards, 1) + assert.Len(t, workflows, 1) - // Client 2 lists workflow steps (created automatically with board) + // Client 2 lists workflow steps (created automatically with workflow) stepResp, err := client2.SendRequest("c2-step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "board_id": boardID, + "workflow_id": workflowID, }) require.NoError(t, err) @@ -820,7 +820,7 @@ func TestMultipleClients(t *testing.T) { // Client 1 can also see the workflow steps stepListResp, err := client1.SendRequest("c1-step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "board_id": boardID, + "workflow_id": workflowID, }) require.NoError(t, err) @@ -835,7 +835,7 @@ func TestMultipleClients(t *testing.T) { // Client 1 creates a task in the workflow step taskResp, err := client1.SendRequest("c1-task-1", ws.ActionTaskCreate, map[string]interface{}{ "workspace_id": workspaceID, - "board_id": boardID, + "workflow_id": workflowID, "workflow_step_id": workflowStepID, "title": "Task by Client 1", "repository_id": repositoryID, @@ -849,7 +849,7 @@ func TestMultipleClients(t *testing.T) { // Client 2 can see the task taskListResp, err := client2.SendRequest("c2-task-list-1", ws.ActionTaskList, map[string]interface{}{ - "board_id": boardID, + "workflow_id": workflowID, }) require.NoError(t, err) @@ -886,9 +886,9 @@ func TestErrorHandling(t *testing.T) { assert.Equal(t, ws.ErrorCodeUnknownAction, payload.Code) }) - t.Run("GetNonExistentBoard", func(t *testing.T) { - resp, err := client.SendRequest("err-2", ws.ActionBoardGet, map[string]interface{}{ - "id": "non-existent-board-id", + t.Run("GetNonExistentWorkflow", func(t *testing.T) { + resp, err := client.SendRequest("err-2", ws.ActionWorkflowGet, map[string]interface{}{ + "id": "non-existent-workflow-id", }) require.NoError(t, err) @@ -904,7 +904,7 @@ func TestErrorHandling(t *testing.T) { assert.Equal(t, ws.MessageTypeError, resp.Type) }) - t.Run("CreateTaskWithoutBoard", func(t *testing.T) { + t.Run("CreateTaskWithoutWorkflow", func(t *testing.T) { resp, err := client.SendRequest("err-4", ws.ActionTaskCreate, map[string]interface{}{ "title": "Orphan Task", }) @@ -913,7 +913,7 @@ func TestErrorHandling(t *testing.T) { assert.Equal(t, ws.MessageTypeError, resp.Type) }) - t.Run("ListWorkflowStepsWithoutBoard", func(t *testing.T) { + t.Run("ListWorkflowStepsWithoutWorkflow", func(t *testing.T) { resp, err := client.SendRequest("err-5", ws.ActionWorkflowStepList, map[string]interface{}{}) require.NoError(t, err) @@ -935,21 +935,21 @@ func TestTaskSubscription(t *testing.T) { workspaceID := createWorkspace(t, client) repositoryID := createRepository(t, client, workspaceID) - // Create a task first (workflow steps are created automatically with board) - boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ + // Create a task first (workflow steps are created automatically with workflow) + workflowResp, _ := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ "workspace_id": workspaceID, - "name": "Sub Test Board", + "name": "Sub Test Workflow", "workflow_template_id": "simple", }) - var boardPayload map[string]interface{} - if err := boardResp.ParsePayload(&boardPayload); err != nil { - t.Fatalf("failed to parse board payload: %v", err) + var workflowPayload map[string]interface{} + if err := workflowResp.ParsePayload(&workflowPayload); err != nil { + t.Fatalf("failed to parse workflow payload: %v", err) } - boardID := boardPayload["id"].(string) + workflowID := workflowPayload["id"].(string) // Get first workflow step stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "board_id": boardID, + "workflow_id": workflowID, }) var stepListPayload map[string]interface{} if err := stepResp.ParsePayload(&stepListPayload); err != nil { @@ -960,7 +960,7 @@ func TestTaskSubscription(t *testing.T) { taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ "workspace_id": workspaceID, - "board_id": boardID, + "workflow_id": workflowID, "workflow_step_id": workflowStepID, "title": "Subscribable Task", "repository_id": repositoryID, @@ -1018,21 +1018,21 @@ func TestConcurrentRequests(t *testing.T) { workspaceID := createWorkspace(t, client) repositoryID := createRepository(t, client, workspaceID) - // Create board (workflow steps are created automatically) - boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ + // Create workflow (workflow steps are created automatically) + workflowResp, _ := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ "workspace_id": workspaceID, - "name": "Concurrent Test Board", + "name": "Concurrent Test Workflow", "workflow_template_id": "simple", }) - var boardPayload map[string]interface{} - if err := boardResp.ParsePayload(&boardPayload); err != nil { - t.Fatalf("failed to parse board payload: %v", err) + var workflowPayload map[string]interface{} + if err := workflowResp.ParsePayload(&workflowPayload); err != nil { + t.Fatalf("failed to parse workflow payload: %v", err) } - boardID := boardPayload["id"].(string) + workflowID := workflowPayload["id"].(string) // Get first workflow step stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "board_id": boardID, + "workflow_id": workflowID, }) var stepListPayload map[string]interface{} if err := stepResp.ParsePayload(&stepListPayload); err != nil { @@ -1060,7 +1060,7 @@ func TestConcurrentRequests(t *testing.T) { ws.ActionTaskCreate, map[string]interface{}{ "workspace_id": workspaceID, - "board_id": boardID, + "workflow_id": workflowID, "workflow_step_id": workflowStepID, "title": "Concurrent Task " + string(rune('0'+idx)), "repository_id": repositoryID, @@ -1081,7 +1081,7 @@ func TestConcurrentRequests(t *testing.T) { // Verify task count listResp, err := client.SendRequest("list-1", ws.ActionTaskList, map[string]interface{}{ - "board_id": boardID, + "workflow_id": workflowID, }) require.NoError(t, err) diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index efb953d3df..d95f3d41d7 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -697,15 +697,15 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer { // Register handlers (HTTP + WS) workspaceController := taskcontroller.NewWorkspaceController(taskSvc) - boardController := taskcontroller.NewBoardController(taskSvc) - boardController.SetWorkflowStepLister(workflowSvc) + taskWorkflowController := taskcontroller.NewWorkflowController(taskSvc) + taskWorkflowController.SetWorkflowStepLister(workflowSvc) taskController := taskcontroller.NewTaskController(taskSvc) executorController := taskcontroller.NewExecutorController(taskSvc) environmentController := taskcontroller.NewEnvironmentController(taskSvc) repositoryController := taskcontroller.NewRepositoryController(taskSvc) workflowCtrl := workflowcontroller.NewController(workflowSvc) taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) - taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log) + taskhandlers.RegisterWorkflowRoutes(router, gateway.Dispatcher, taskWorkflowController, log) planService := taskservice.NewPlanService(taskRepo, eventBus, log) taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, nil, taskRepo, planService, log) taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log) @@ -754,20 +754,20 @@ func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentProfileID st }) require.NoError(t, err) - // Create board first (workflow steps are created automatically via default template) + // Create workflow first (workflow steps are created automatically via default template) defaultTemplateID := "simple" - board, err := ts.TaskSvc.CreateBoard(context.Background(), &taskservice.CreateBoardRequest{ + wf, err := ts.TaskSvc.CreateWorkflow(context.Background(), &taskservice.CreateWorkflowRequest{ WorkspaceID: workspace.ID, - Name: "Test Board", - Description: "Test board for orchestrator", + Name: "Test Workflow", + Description: "Test workflow for orchestrator", WorkflowTemplateID: &defaultTemplateID, }) require.NoError(t, err) - // Get first workflow step from board - steps, err := ts.WorkflowSvc.ListStepsByBoard(context.Background(), board.ID) + // Get first workflow step from workflow + steps, err := ts.WorkflowSvc.ListStepsByWorkflow(context.Background(), wf.ID) require.NoError(t, err) - require.NotEmpty(t, steps, "board should have workflow steps") + require.NotEmpty(t, steps, "workflow should have workflow steps") workflowStepID := steps[0].ID // Create task with agent profile ID @@ -780,7 +780,7 @@ func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentProfileID st task, err := ts.TaskSvc.CreateTask(context.Background(), &taskservice.CreateTaskRequest{ WorkspaceID: workspace.ID, - BoardID: board.ID, + WorkflowID: wf.ID, WorkflowStepID: workflowStepID, Title: "Test Task", Description: "This is a test task for the orchestrator", @@ -1623,29 +1623,29 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) { workspaceID := createOrchestratorWorkspace(t, client) - // 1. Create board with workflow template - boardResp, err := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ + // 1. Create workflow with workflow template + workflowResp, err := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ "workspace_id": workspaceID, - "name": "E2E Test Board", - "description": "End-to-end test board", + "name": "E2E Test Workflow", + "description": "End-to-end test workflow", "workflow_template_id": "simple", }) require.NoError(t, err) - var boardPayload map[string]interface{} - require.NoError(t, boardResp.ParsePayload(&boardPayload)) - boardID := boardPayload["id"].(string) + var workflowPayload map[string]interface{} + require.NoError(t, workflowResp.ParsePayload(&workflowPayload)) + workflowID := workflowPayload["id"].(string) - // 2. Get first workflow step from board + // 2. Get first workflow step from workflow stepResp, err := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "board_id": boardID, + "workflow_id": workflowID, }) require.NoError(t, err) var stepListPayload map[string]interface{} require.NoError(t, stepResp.ParsePayload(&stepListPayload)) steps := stepListPayload["steps"].([]interface{}) - require.NotEmpty(t, steps, "board should have workflow steps") + require.NotEmpty(t, steps, "workflow should have workflow steps") workflowStepID := steps[0].(map[string]interface{})["id"].(string) // 3. Create task with workflow step @@ -1663,7 +1663,7 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) { taskResp, err := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ "workspace_id": workspaceID, - "board_id": boardID, + "workflow_id": workflowID, "workflow_step_id": workflowStepID, "title": "Implement feature X", "description": "Create a new feature with tests", diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 43db3e20cd..f630eb9067 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -51,7 +51,7 @@ type EventBus interface { // Handlers provides MCP WebSocket handlers. type Handlers struct { workspaceCtrl *controller.WorkspaceController - boardCtrl *controller.BoardController + taskWorkflowCtrl *controller.WorkflowController taskCtrl *controller.TaskController workflowCtrl *workflowctrl.Controller clarificationSvc ClarificationService @@ -66,7 +66,7 @@ type Handlers struct { // NewHandlers creates new MCP handlers. func NewHandlers( workspaceCtrl *controller.WorkspaceController, - boardCtrl *controller.BoardController, + taskWorkflowCtrl *controller.WorkflowController, taskCtrl *controller.TaskController, workflowCtrl *workflowctrl.Controller, clarificationSvc ClarificationService, @@ -79,7 +79,7 @@ func NewHandlers( ) *Handlers { return &Handlers{ workspaceCtrl: workspaceCtrl, - boardCtrl: boardCtrl, + taskWorkflowCtrl: taskWorkflowCtrl, taskCtrl: taskCtrl, workflowCtrl: workflowCtrl, clarificationSvc: clarificationSvc, @@ -95,7 +95,7 @@ func NewHandlers( // RegisterHandlers registers all MCP handlers with the dispatcher. func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPListWorkspaces, h.handleListWorkspaces) - d.RegisterFunc(ws.ActionMCPListBoards, h.handleListBoards) + d.RegisterFunc(ws.ActionMCPListWorkflows, h.handleListWorkflows) d.RegisterFunc(ws.ActionMCPListWorkflowSteps, h.handleListWorkflowSteps) d.RegisterFunc(ws.ActionMCPListTasks, h.handleListTasks) d.RegisterFunc(ws.ActionMCPCreateTask, h.handleCreateTask) @@ -120,8 +120,8 @@ func (h *Handlers) handleListWorkspaces(ctx context.Context, msg *ws.Message) (* return ws.NewResponse(msg.ID, msg.Action, resp) } -// handleListBoards lists boards for a workspace. -func (h *Handlers) handleListBoards(ctx context.Context, msg *ws.Message) (*ws.Message, error) { +// handleListWorkflows lists workflows for a workspace. +func (h *Handlers) handleListWorkflows(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { WorkspaceID string `json:"workspace_id"` } @@ -132,28 +132,28 @@ func (h *Handlers) handleListBoards(ctx context.Context, msg *ws.Message) (*ws.M return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_id is required", nil) } - resp, err := h.boardCtrl.ListBoards(ctx, dto.ListBoardsRequest{WorkspaceID: req.WorkspaceID}) + resp, err := h.taskWorkflowCtrl.ListWorkflows(ctx, dto.ListWorkflowsRequest{WorkspaceID: req.WorkspaceID}) if err != nil { - h.logger.Error("failed to list boards", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list boards", nil) + h.logger.Error("failed to list workflows", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list workflows", nil) } return ws.NewResponse(msg.ID, msg.Action, resp) } -// handleListWorkflowSteps lists workflow steps for a board. +// handleListWorkflowSteps lists workflow steps for a workflow. func (h *Handlers) handleListWorkflowSteps(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { - BoardID string `json:"board_id"` + WorkflowID string `json:"workflow_id"` } if err := json.Unmarshal(msg.Payload, &req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } - if req.BoardID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil) + if req.WorkflowID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) } - resp, err := h.workflowCtrl.ListStepsByBoard(ctx, workflowctrl.ListStepsRequest{BoardID: req.BoardID}) + resp, err := h.workflowCtrl.ListStepsByWorkflow(ctx, workflowctrl.ListStepsRequest{WorkflowID: req.WorkflowID}) if err != nil { h.logger.Error("failed to list workflow steps", zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list workflow steps", nil) @@ -162,19 +162,19 @@ func (h *Handlers) handleListWorkflowSteps(ctx context.Context, msg *ws.Message) return ws.NewResponse(msg.ID, msg.Action, resp) } -// handleListTasks lists tasks for a board. +// handleListTasks lists tasks for a workflow. func (h *Handlers) handleListTasks(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { - BoardID string `json:"board_id"` + WorkflowID string `json:"workflow_id"` } if err := json.Unmarshal(msg.Payload, &req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } - if req.BoardID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil) + if req.WorkflowID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) } - resp, err := h.taskCtrl.ListTasks(ctx, dto.ListTasksRequest{BoardID: req.BoardID}) + resp, err := h.taskCtrl.ListTasks(ctx, dto.ListTasksRequest{WorkflowID: req.WorkflowID}) if err != nil { h.logger.Error("failed to list tasks", zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list tasks", nil) @@ -192,8 +192,8 @@ func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.M if req.WorkspaceID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_id is required", nil) } - if req.BoardID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil) + if req.WorkflowID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) } if req.WorkflowStepID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_step_id is required", nil) diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go index 9daad767e2..5fbd8aa09f 100644 --- a/apps/backend/internal/orchestrator/event_handlers.go +++ b/apps/backend/internal/orchestrator/event_handlers.go @@ -15,6 +15,7 @@ import ( "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/orchestrator/watcher" "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -137,7 +138,7 @@ func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventD // Check for workflow transition based on session's current step // This handles the case where the agent finishes a step and should move to the next - transitioned := s.handleWorkflowTransition(ctx, data.TaskID, data.SessionID) + transitioned := s.processOnTurnComplete(ctx, data.TaskID, data.SessionID) // If no workflow transition occurred, move session to waiting for input and task to review if !transitioned { @@ -244,7 +245,7 @@ func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEv s.scheduler.RemoveTask(data.TaskID) // Check for workflow transition based on session's current step - transitioned := s.handleWorkflowTransition(ctx, data.TaskID, data.SessionID) + transitioned := s.processOnTurnComplete(ctx, data.TaskID, data.SessionID) // If no workflow transition occurred, move task to REVIEW state for user review if !transitioned { @@ -259,9 +260,9 @@ func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEv } } -// handleWorkflowTransition checks if the session's current workflow step has an on_complete_step_id -// and moves the task to that step if so. Returns true if a transition occurred. -func (s *Service) handleWorkflowTransition(ctx context.Context, taskID, sessionID string) bool { +// processOnTurnComplete processes the on_turn_complete events for the current step. +// Returns true if a transition occurred (step change happened). +func (s *Service) processOnTurnComplete(ctx context.Context, taskID, sessionID string) bool { if sessionID == "" || s.workflowStepGetter == nil { return false } @@ -292,91 +293,237 @@ func (s *Service) handleWorkflowTransition(ctx context.Context, taskID, sessionI return false } - // If the CURRENT step requires approval, don't transition - stay here and wait for approval - // This handles the case where a step has both AutoStartAgent and RequireApproval enabled - if currentStep.RequireApproval { - s.logger.Info("current step requires approval, staying on step", - zap.String("session_id", sessionID), + // If no on_turn_complete actions, do nothing (manual step) + if len(currentStep.Events.OnTurnComplete) == 0 { + s.logger.Debug("step has no on_turn_complete actions, waiting for user", zap.String("step_id", currentStep.ID), zap.String("step_name", currentStep.Name)) - - // Set review status to pending - if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, "pending"); err != nil { - s.logger.Warn("failed to set session review status to pending", - zap.String("session_id", sessionID), - zap.Error(err)) - } else { - s.logger.Info("session review status set to pending", - zap.String("session_id", sessionID), - zap.String("current_step", currentStep.Name)) - } - - // Update session state to WAITING_FOR_INPUT and task state to REVIEW s.setSessionWaitingForInput(ctx, taskID, sessionID) - - // Publish session updated event with review status - if s.eventBus != nil { - eventData := map[string]interface{}{ - "task_id": taskID, - "session_id": sessionID, - "workflow_step_id": currentStep.ID, - "new_state": string(models.TaskSessionStateWaitingForInput), - "review_status": "pending", - } - _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent( - events.TaskSessionStateChanged, - "orchestrator", - eventData, - )) - } - - // Return true to indicate we handled the completion (no further transition needed) - return true - } - - // Check if there's an on_complete transition - if currentStep.OnCompleteStepID == "" { - s.logger.Debug("workflow step has no on_complete transition", - zap.String("step_id", currentStep.ID), - zap.String("step_name", currentStep.Name)) return false } - // Get the target step to log its name - targetStep, err := s.workflowStepGetter.GetStep(ctx, currentStep.OnCompleteStepID) + // Process side-effect actions first, then find the first transition action + var transitionAction *wfmodels.OnTurnCompleteAction + for i := range currentStep.Events.OnTurnComplete { + action := ¤tStep.Events.OnTurnComplete[i] + switch action.Type { + case wfmodels.OnTurnCompleteDisablePlanMode: + // Side-effect: clear plan mode from session metadata + s.clearSessionPlanMode(ctx, sessionID) + case wfmodels.OnTurnCompleteMoveToNext, wfmodels.OnTurnCompleteMoveToPrevious, wfmodels.OnTurnCompleteMoveToStep: + // Skip transition actions that require approval (handled via manual approval flow) + if action.Config != nil { + if ra, ok := action.Config["requires_approval"].(bool); ok && ra { + continue + } + } + if transitionAction == nil { + transitionAction = action + } + } + } + + // If no transition action found, just apply side effects and wait + if transitionAction == nil { + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return false + } + + // Resolve the target step ID + var targetStepID string + switch transitionAction.Type { + case wfmodels.OnTurnCompleteMoveToNext: + nextStep, err := s.workflowStepGetter.GetNextStepByPosition(ctx, currentStep.WorkflowID, currentStep.Position) + if err != nil { + s.logger.Warn("failed to get next step by position", + zap.String("workflow_id", currentStep.WorkflowID), + zap.Int("current_position", currentStep.Position), + zap.Error(err)) + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return false + } + if nextStep == nil { + s.logger.Debug("no next step found (last step), staying", + zap.String("step_name", currentStep.Name)) + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return false + } + targetStepID = nextStep.ID + case wfmodels.OnTurnCompleteMoveToPrevious: + prevStep, err := s.workflowStepGetter.GetPreviousStepByPosition(ctx, currentStep.WorkflowID, currentStep.Position) + if err != nil { + s.logger.Warn("failed to get previous step by position", + zap.String("workflow_id", currentStep.WorkflowID), + zap.Int("current_position", currentStep.Position), + zap.Error(err)) + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return false + } + if prevStep == nil { + s.logger.Debug("no previous step found (first step), staying", + zap.String("step_name", currentStep.Name)) + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return false + } + targetStepID = prevStep.ID + case wfmodels.OnTurnCompleteMoveToStep: + if transitionAction.Config != nil { + if sid, ok := transitionAction.Config["step_id"].(string); ok { + targetStepID = sid + } + } + if targetStepID == "" { + s.logger.Warn("move_to_step action missing step_id config", + zap.String("step_id", currentStep.ID)) + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return false + } + } + + // Execute the step transition + s.executeStepTransition(ctx, taskID, sessionID, currentStep, targetStepID, true) + return true +} + +// processOnTurnStart processes the on_turn_start events for the current step. +// This is called when a user sends a message. Returns true if a transition occurred. +func (s *Service) processOnTurnStart(ctx context.Context, taskID, sessionID string) bool { + if sessionID == "" || s.workflowStepGetter == nil { + return false + } + + // Get the session to find its current workflow step + session, err := s.repo.GetTaskSession(ctx, sessionID) if err != nil { - s.logger.Warn("failed to get target workflow step", - zap.String("target_step_id", currentStep.OnCompleteStepID), + s.logger.Warn("failed to get session for on_turn_start", + zap.String("session_id", sessionID), zap.Error(err)) return false } + if session.WorkflowStepID == nil || *session.WorkflowStepID == "" { + return false + } + + workflowStepID := *session.WorkflowStepID + + // Get the current workflow step + currentStep, err := s.workflowStepGetter.GetStep(ctx, workflowStepID) + if err != nil { + s.logger.Warn("failed to get workflow step for on_turn_start", + zap.String("workflow_step_id", workflowStepID), + zap.Error(err)) + return false + } + + // If no on_turn_start actions, do nothing + if len(currentStep.Events.OnTurnStart) == 0 { + return false + } + + // Find the first transition action + var transitionAction *wfmodels.OnTurnStartAction + for i := range currentStep.Events.OnTurnStart { + action := ¤tStep.Events.OnTurnStart[i] + switch action.Type { + case wfmodels.OnTurnStartMoveToNext, wfmodels.OnTurnStartMoveToPrevious, wfmodels.OnTurnStartMoveToStep: + if transitionAction == nil { + transitionAction = action + } + } + } + + if transitionAction == nil { + return false + } + + // Resolve the target step ID + var targetStepID string + switch transitionAction.Type { + case wfmodels.OnTurnStartMoveToNext: + nextStep, err := s.workflowStepGetter.GetNextStepByPosition(ctx, currentStep.WorkflowID, currentStep.Position) + if err != nil || nextStep == nil { + return false + } + targetStepID = nextStep.ID + case wfmodels.OnTurnStartMoveToPrevious: + prevStep, err := s.workflowStepGetter.GetPreviousStepByPosition(ctx, currentStep.WorkflowID, currentStep.Position) + if err != nil || prevStep == nil { + return false + } + targetStepID = prevStep.ID + case wfmodels.OnTurnStartMoveToStep: + if transitionAction.Config != nil { + if sid, ok := transitionAction.Config["step_id"].(string); ok { + targetStepID = sid + } + } + if targetStepID == "" { + return false + } + } + + s.logger.Info("on_turn_start triggered step transition", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("from_step", currentStep.Name), + zap.String("action", string(transitionAction.Type))) + + // Execute the step transition WITHOUT triggering on_enter auto-start + // (user is about to send a message, the prompt will come from them) + s.executeStepTransition(ctx, taskID, sessionID, currentStep, targetStepID, false) + return true +} + +// ProcessOnTurnStart is the public API for triggering on_turn_start events. +// Called by message handlers before sending a prompt to the agent. +func (s *Service) ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error { + s.processOnTurnStart(ctx, taskID, sessionID) + return nil +} + +// executeStepTransition moves a task/session from one step to another. +// If triggerOnEnter is true, on_enter actions (like auto_start_agent) are processed. +// If false, only the step change is applied (used for on_turn_start where the user is about to send a message). +func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID string, fromStep *wfmodels.WorkflowStep, toStepID string, triggerOnEnter bool) { + // Get the target step + targetStep, err := s.workflowStepGetter.GetStep(ctx, toStepID) + if err != nil { + s.logger.Warn("failed to get target workflow step", + zap.String("target_step_id", toStepID), + zap.Error(err)) + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return + } + // Get the task to update its workflow step task, err := s.repo.GetTask(ctx, taskID) if err != nil { s.logger.Warn("failed to get task for workflow transition", zap.String("task_id", taskID), zap.Error(err)) - return false + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return } // Update the task's workflow step - task.WorkflowStepID = currentStep.OnCompleteStepID + task.WorkflowStepID = toStepID task.UpdatedAt = time.Now().UTC() if err := s.repo.UpdateTask(ctx, task); err != nil { s.logger.Error("failed to move task to next workflow step", zap.String("task_id", taskID), - zap.String("from_step", currentStep.Name), + zap.String("from_step", fromStep.Name), zap.String("to_step", targetStep.Name), zap.Error(err)) - return false + s.setSessionWaitingForInput(ctx, taskID, sessionID) + return } - // Publish task updated event so frontend updates the kanban board + // Publish task updated event if s.eventBus != nil { taskEventData := map[string]interface{}{ "task_id": task.ID, - "board_id": task.BoardID, + "workflow_id": task.WorkflowID, "workflow_step_id": task.WorkflowStepID, "title": task.Title, "description": task.Description, @@ -391,186 +538,152 @@ func (s *Service) handleWorkflowTransition(ctx context.Context, taskID, sessionI )) } - // Also update the session's workflow step - if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, currentStep.OnCompleteStepID); err != nil { + // Update session's workflow step + if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, toStepID); err != nil { s.logger.Warn("failed to update session workflow step", zap.String("session_id", sessionID), - zap.String("step_id", currentStep.OnCompleteStepID), + zap.String("step_id", toStepID), zap.Error(err)) - // Don't return false - task was already moved } - // If the target step requires approval, set the session's review_status to "pending" - var reviewStatus string - if targetStep.RequireApproval { - reviewStatus = "pending" - if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, reviewStatus); err != nil { - s.logger.Warn("failed to set session review status to pending", - zap.String("session_id", sessionID), - zap.Error(err)) - reviewStatus = "" // Clear on error - } else { - s.logger.Info("session review status set to pending", - zap.String("session_id", sessionID), - zap.String("target_step", targetStep.Name)) - } - } else { - // Target step does not require approval - clear any existing review status - if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil { - s.logger.Warn("failed to clear session review status", - zap.String("session_id", sessionID), - zap.Error(err)) - } else { - s.logger.Debug("session review status cleared for non-review step", - zap.String("session_id", sessionID), - zap.String("target_step", targetStep.Name)) - } - } - - // Update session state to WAITING_FOR_INPUT and task state to REVIEW since the agent completed this step - // This must happen before publishing the event so the frontend gets the correct state - s.setSessionWaitingForInput(ctx, taskID, sessionID) - - // Publish session updated event with workflow step and review status changes - // This allows the frontend to update the session state without a full refresh - if s.eventBus != nil { - eventData := map[string]interface{}{ - "task_id": taskID, - "session_id": sessionID, - "workflow_step_id": targetStep.ID, - "new_state": string(models.TaskSessionStateWaitingForInput), - } - if reviewStatus != "" { - eventData["review_status"] = reviewStatus - } - _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent( - events.TaskSessionStateChanged, - "orchestrator", - eventData, - )) - } - - s.logger.Info("workflow transition completed", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.String("from_step", currentStep.Name), - zap.String("to_step", targetStep.Name)) - - return true -} - -// handleReviewStepRollback checks if the session is in a review-type step. -// If so, it moves the session back to the previous step (the step that led to the review step) -// and clears the review status. This handles the case where the user sends a message to -// iterate on the work instead of approving. -// Note: This works for both "simple" boards (where review steps don't require approval) and -// "advanced" boards (where review steps have RequireApproval=true). -func (s *Service) handleReviewStepRollback(ctx context.Context, taskID, sessionID string) { - if sessionID == "" || s.workflowStepGetter == nil { - return - } - - // Get the session to check its current workflow step - session, err := s.repo.GetTaskSession(ctx, sessionID) - if err != nil { - s.logger.Warn("failed to get session for review rollback check", - zap.String("session_id", sessionID), - zap.Error(err)) - return - } - - // Only proceed if session has a workflow step - if session.WorkflowStepID == nil || *session.WorkflowStepID == "" { - return - } - - currentStepID := *session.WorkflowStepID - - // Get the current workflow step to check if it's a review step - currentStep, err := s.workflowStepGetter.GetStep(ctx, currentStepID) - if err != nil { - s.logger.Warn("failed to get workflow step for review rollback", - zap.String("workflow_step_id", currentStepID), - zap.Error(err)) - return - } - - // Only proceed if the current step is a pure review step (StepType == "review") - // Work steps like Planning or Implementation with RequireApproval should NOT rollback - // because the agent is actively working on them, not just waiting for approval - if currentStep.StepType != "review" { - return - } - - // Get the task to find the board ID - task, err := s.repo.GetTask(ctx, taskID) - if err != nil { - s.logger.Warn("failed to get task for review rollback", - zap.String("task_id", taskID), - zap.Error(err)) - return - } - - // Find the source step (the step that has on_complete_step_id pointing to current step) - sourceStep, err := s.workflowStepGetter.GetSourceStep(ctx, task.BoardID, currentStepID) - if err != nil { - s.logger.Warn("failed to get source step for review rollback", - zap.String("current_step_id", currentStepID), - zap.Error(err)) - return - } - if sourceStep == nil { - s.logger.Debug("no source step found for review rollback, staying in current step", - zap.String("current_step_id", currentStepID)) - return - } - - // Move session back to the source step - if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, sourceStep.ID); err != nil { - s.logger.Error("failed to move session back to source step", - zap.String("session_id", sessionID), - zap.String("source_step_id", sourceStep.ID), - zap.Error(err)) - return - } - - // Clear the review status + // Clear review status when transitioning if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil { s.logger.Warn("failed to clear session review status", zap.String("session_id", sessionID), zap.Error(err)) } - // Also move the task to the source step - task.WorkflowStepID = sourceStep.ID - if err := s.repo.UpdateTask(ctx, task); err != nil { - s.logger.Error("failed to move task back to source step", - zap.String("task_id", taskID), - zap.String("source_step_id", sourceStep.ID), - zap.Error(err)) - } - - // Publish session state change event so frontend updates - if s.eventBus != nil { - eventData := map[string]interface{}{ - "task_id": taskID, - "session_id": sessionID, - "workflow_step_id": sourceStep.ID, - "new_state": string(session.State), - "review_status": "", // Cleared - } - _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent( - events.TaskSessionStateChanged, - "orchestrator", - eventData, - )) - } - - s.logger.Info("session moved back from review step", + s.logger.Info("workflow transition completed", zap.String("task_id", taskID), zap.String("session_id", sessionID), - zap.String("from_step", currentStep.Name), - zap.String("to_step", sourceStep.Name)) + zap.String("from_step", fromStep.Name), + zap.String("to_step", targetStep.Name), + zap.Bool("trigger_on_enter", triggerOnEnter)) + + // Process on_enter for the target step (skip if triggerOnEnter is false, + // e.g. on_turn_start transitions where the user is about to send a message) + if triggerOnEnter { + s.processOnEnter(ctx, taskID, sessionID, targetStep, task.Description) + } else { + s.setSessionWaitingForInput(ctx, taskID, sessionID) + } +} + +// processOnEnter processes the on_enter events for a step after transitioning to it. +func (s *Service) processOnEnter(ctx context.Context, taskID, sessionID string, step *wfmodels.WorkflowStep, taskDescription string) { + // Check if this step enables plan mode + hasPlanMode := false + for _, action := range step.Events.OnEnter { + if action.Type == wfmodels.OnEnterEnablePlanMode { + hasPlanMode = true + break + } + } + + // Auto-disable plan mode when entering a step that doesn't explicitly enable it + if !hasPlanMode { + s.clearSessionPlanMode(ctx, sessionID) + } + + if len(step.Events.OnEnter) == 0 { + s.setSessionWaitingForInput(ctx, taskID, sessionID) + + // Publish session state change + if s.eventBus != nil { + eventData := map[string]interface{}{ + "task_id": taskID, + "session_id": sessionID, + "workflow_step_id": step.ID, + "new_state": string(models.TaskSessionStateWaitingForInput), + } + _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent( + events.TaskSessionStateChanged, + "orchestrator", + eventData, + )) + } + return + } + + hasAutoStart := false + for _, action := range step.Events.OnEnter { + switch action.Type { + case wfmodels.OnEnterEnablePlanMode: + s.setSessionPlanMode(ctx, sessionID, true) + case wfmodels.OnEnterAutoStartAgent: + hasAutoStart = true + } + } + + if hasAutoStart { + // Build prompt from step configuration + effectivePrompt := s.buildWorkflowPrompt(taskDescription, step, taskID) + planMode := step.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode) + + // Auto-start the agent + go func() { + bgCtx := context.Background() + _, err := s.PromptTask(bgCtx, taskID, sessionID, effectivePrompt, "", planMode, nil) + if err != nil { + s.logger.Error("failed to auto-start agent for step", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("step_name", step.Name), + zap.Error(err)) + s.setSessionWaitingForInput(bgCtx, taskID, sessionID) + } + }() + } else { + s.setSessionWaitingForInput(ctx, taskID, sessionID) + + // Publish session state change + if s.eventBus != nil { + eventData := map[string]interface{}{ + "task_id": taskID, + "session_id": sessionID, + "workflow_step_id": step.ID, + "new_state": string(models.TaskSessionStateWaitingForInput), + } + _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent( + events.TaskSessionStateChanged, + "orchestrator", + eventData, + )) + } + } +} + +// clearSessionPlanMode clears plan mode from session metadata. +func (s *Service) clearSessionPlanMode(ctx context.Context, sessionID string) { + s.setSessionPlanMode(ctx, sessionID, false) +} + +// setSessionPlanMode sets or clears plan mode in session metadata. +func (s *Service) setSessionPlanMode(ctx context.Context, sessionID string, enabled bool) { + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + s.logger.Warn("failed to get session for plan mode update", + zap.String("session_id", sessionID), + zap.Error(err)) + return + } + + if session.Metadata == nil { + session.Metadata = make(map[string]interface{}) + } + + if enabled { + session.Metadata["plan_mode"] = true + } else { + delete(session.Metadata, "plan_mode") + } + + if err := s.repo.UpdateTaskSession(ctx, session); err != nil { + s.logger.Warn("failed to update session plan mode", + zap.String("session_id", sessionID), + zap.Bool("enabled", enabled), + zap.Error(err)) + } } // handleAgentFailed handles agent failure events diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index 34ae42f271..a2ecf191d5 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -1,6 +1,178 @@ package orchestrator -import "testing" +import ( + "context" + "path/filepath" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// --- Mocks --- + +// mockStepGetter implements WorkflowStepGetter for testing. +type mockStepGetter struct { + steps map[string]*wfmodels.WorkflowStep // stepID -> step +} + +func newMockStepGetter() *mockStepGetter { + return &mockStepGetter{steps: make(map[string]*wfmodels.WorkflowStep)} +} + +func (m *mockStepGetter) GetStep(_ context.Context, stepID string) (*wfmodels.WorkflowStep, error) { + if s, ok := m.steps[stepID]; ok { + return s, nil + } + return nil, nil +} + +func (m *mockStepGetter) GetNextStepByPosition(_ context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) { + var best *wfmodels.WorkflowStep + for _, s := range m.steps { + if s.WorkflowID == workflowID && s.Position > currentPosition { + if best == nil || s.Position < best.Position { + best = s + } + } + } + return best, nil +} + +func (m *mockStepGetter) GetPreviousStepByPosition(_ context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) { + var best *wfmodels.WorkflowStep + for _, s := range m.steps { + if s.WorkflowID == workflowID && s.Position < currentPosition { + if best == nil || s.Position > best.Position { + best = s + } + } + } + return best, nil +} + +// mockTaskRepo implements scheduler.TaskRepository for testing. +type mockTaskRepo struct { + tasks map[string]*v1.Task + updatedStates map[string]v1.TaskState +} + +func newMockTaskRepo() *mockTaskRepo { + return &mockTaskRepo{ + tasks: make(map[string]*v1.Task), + updatedStates: make(map[string]v1.TaskState), + } +} + +func (m *mockTaskRepo) GetTask(_ context.Context, taskID string) (*v1.Task, error) { + if t, ok := m.tasks[taskID]; ok { + return t, nil + } + return nil, nil +} + +func (m *mockTaskRepo) UpdateTaskState(_ context.Context, taskID string, state v1.TaskState) error { + m.updatedStates[taskID] = state + return nil +} + +// --- Helpers --- + +func testLogger() *logger.Logger { + log, _ := logger.NewLogger(logger.LoggingConfig{ + Level: "error", + Format: "console", + }) + return log +} + +func strPtr(s string) *string { return &s } + +// setupTestRepo creates a real in-memory SQLite repository for testing. +func setupTestRepo(t *testing.T) repository.Repository { + t.Helper() + tmpDir := t.TempDir() + dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db")) + if err != nil { + t.Fatalf("failed to open test database: %v", err) + } + t.Cleanup(func() { _ = dbConn.Close() }) + + repo, cleanup, err := repository.Provide(dbConn) + if err != nil { + t.Fatalf("failed to create test repository: %v", err) + } + t.Cleanup(func() { _ = cleanup() }) + + return repo +} + +// seedSession creates a task, workspace, workflow and session in the repo for testing. +func seedSession(t *testing.T, repo repository.Repository, taskID, sessionID, workflowStepID string) { + t.Helper() + ctx := context.Background() + now := time.Now().UTC() + + // Create workspace + ws := &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now} + if err := repo.CreateWorkspace(ctx, ws); err != nil { + t.Fatalf("failed to create workspace: %v", err) + } + + // Create workflow + wf := &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "Test Workflow", CreatedAt: now, UpdatedAt: now} + if err := repo.CreateWorkflow(ctx, wf); err != nil { + // Might already exist + _ = err + } + + // Create task + task := &models.Task{ + ID: taskID, + WorkflowID: "wf1", + WorkflowStepID: workflowStepID, + Title: "Test Task", + Description: "Test", + State: v1.TaskStateInProgress, + CreatedAt: now, + UpdatedAt: now, + } + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("failed to create task: %v", err) + } + + // Create session + session := &models.TaskSession{ + ID: sessionID, + TaskID: taskID, + State: models.TaskSessionStateRunning, + WorkflowStepID: strPtr(workflowStepID), + StartedAt: now, + UpdatedAt: now, + } + if err := repo.CreateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to create task session: %v", err) + } +} + +// createTestService creates a Service with minimal dependencies for event handler testing. +func createTestService(repo repository.Repository, stepGetter *mockStepGetter, taskRepo *mockTaskRepo) *Service { + return &Service{ + logger: testLogger(), + repo: repo, + workflowStepGetter: stepGetter, + taskRepo: taskRepo, + } +} + +// --- Tests --- func TestIsResumeFailure(t *testing.T) { tests := []struct { @@ -8,36 +180,12 @@ func TestIsResumeFailure(t *testing.T) { errorMsg string want bool }{ - { - name: "exact match", - errorMsg: "no conversation found", - want: true, - }, - { - name: "mixed case", - errorMsg: "No Conversation Found", - want: true, - }, - { - name: "embedded in longer message", - errorMsg: "prompt failed: no conversation found for session abc-123", - want: true, - }, - { - name: "unrelated error", - errorMsg: "connection refused", - want: false, - }, - { - name: "empty string", - errorMsg: "", - want: false, - }, - { - name: "agent crashed", - errorMsg: "agent process exited with code 1", - want: false, - }, + {name: "exact match", errorMsg: "no conversation found", want: true}, + {name: "mixed case", errorMsg: "No Conversation Found", want: true}, + {name: "embedded in longer message", errorMsg: "prompt failed: no conversation found for session abc-123", want: true}, + {name: "unrelated error", errorMsg: "connection refused", want: false}, + {name: "empty string", errorMsg: "", want: false}, + {name: "agent crashed", errorMsg: "agent process exited with code 1", want: false}, } for _, tt := range tests { @@ -49,3 +197,371 @@ func TestIsResumeFailure(t *testing.T) { }) } } + +func TestProcessOnTurnComplete(t *testing.T) { + ctx := context.Background() + + t.Run("no session step returns false", func(t *testing.T) { + repo := setupTestRepo(t) + // Create session without workflow step + now := time.Now().UTC() + ws := &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now} + _ = repo.CreateWorkspace(ctx, ws) + wf := &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "WF", CreatedAt: now, UpdatedAt: now} + _ = repo.CreateWorkflow(ctx, wf) + task := &models.Task{ID: "t1", WorkflowID: "wf1", Title: "T", State: v1.TaskStateInProgress, CreatedAt: now, UpdatedAt: now} + _ = repo.CreateTask(ctx, task) + session := &models.TaskSession{ID: "s1", TaskID: "t1", State: models.TaskSessionStateRunning, StartedAt: now, UpdatedAt: now} + _ = repo.CreateTaskSession(ctx, session) + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + got := svc.processOnTurnComplete(ctx, "t1", "s1") + if got { + t.Error("expected false when session has no workflow step") + } + }) + + t.Run("no actions returns false", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + stepGetter := newMockStepGetter() + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Step 1", Position: 0, + Events: wfmodels.StepEvents{}, // no actions + } + + taskRepo := newMockTaskRepo() + svc := createTestService(repo, stepGetter, taskRepo) + got := svc.processOnTurnComplete(ctx, "t1", "s1") + if got { + t.Error("expected false when step has no on_turn_complete actions") + } + }) + + t.Run("move_to_next transitions to next step", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + stepGetter := newMockStepGetter() + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Step 1", Position: 0, + Events: wfmodels.StepEvents{ + OnTurnComplete: []wfmodels.OnTurnCompleteAction{ + {Type: wfmodels.OnTurnCompleteMoveToNext}, + }, + }, + } + stepGetter.steps["step2"] = &wfmodels.WorkflowStep{ + ID: "step2", WorkflowID: "wf1", Name: "Step 2", Position: 1, + Events: wfmodels.StepEvents{}, + } + + taskRepo := newMockTaskRepo() + svc := createTestService(repo, stepGetter, taskRepo) + got := svc.processOnTurnComplete(ctx, "t1", "s1") + if !got { + t.Error("expected true when move_to_next transitions") + } + + // Verify the session was updated to step2 + session, _ := repo.GetTaskSession(ctx, "s1") + if session.WorkflowStepID == nil || *session.WorkflowStepID != "step2" { + t.Errorf("expected session workflow step to be 'step2', got %v", session.WorkflowStepID) + } + }) + + t.Run("move_to_step transitions to specified step", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + stepGetter := newMockStepGetter() + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Step 1", Position: 0, + Events: wfmodels.StepEvents{ + OnTurnComplete: []wfmodels.OnTurnCompleteAction{ + {Type: wfmodels.OnTurnCompleteMoveToStep, Config: map[string]interface{}{"step_id": "step3"}}, + }, + }, + } + stepGetter.steps["step3"] = &wfmodels.WorkflowStep{ + ID: "step3", WorkflowID: "wf1", Name: "Step 3", Position: 2, + Events: wfmodels.StepEvents{}, + } + + taskRepo := newMockTaskRepo() + svc := createTestService(repo, stepGetter, taskRepo) + got := svc.processOnTurnComplete(ctx, "t1", "s1") + if !got { + t.Error("expected true when move_to_step transitions") + } + + session, _ := repo.GetTaskSession(ctx, "s1") + if session.WorkflowStepID == nil || *session.WorkflowStepID != "step3" { + t.Errorf("expected session workflow step to be 'step3', got %v", session.WorkflowStepID) + } + }) + + t.Run("last step with move_to_next stays", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step_last") + + stepGetter := newMockStepGetter() + stepGetter.steps["step_last"] = &wfmodels.WorkflowStep{ + ID: "step_last", WorkflowID: "wf1", Name: "Last Step", Position: 99, + Events: wfmodels.StepEvents{ + OnTurnComplete: []wfmodels.OnTurnCompleteAction{ + {Type: wfmodels.OnTurnCompleteMoveToNext}, + }, + }, + } + + taskRepo := newMockTaskRepo() + svc := createTestService(repo, stepGetter, taskRepo) + got := svc.processOnTurnComplete(ctx, "t1", "s1") + if got { + t.Error("expected false when at last step with move_to_next (no next step)") + } + }) + + t.Run("requires_approval action is skipped", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + stepGetter := newMockStepGetter() + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Step 1", Position: 0, + Events: wfmodels.StepEvents{ + OnTurnComplete: []wfmodels.OnTurnCompleteAction{ + { + Type: wfmodels.OnTurnCompleteMoveToStep, + Config: map[string]interface{}{ + "step_id": "step2", + "requires_approval": true, + }, + }, + }, + }, + } + + taskRepo := newMockTaskRepo() + svc := createTestService(repo, stepGetter, taskRepo) + got := svc.processOnTurnComplete(ctx, "t1", "s1") + if got { + t.Error("expected false when only action requires_approval") + } + + // Verify session step was NOT changed + session, _ := repo.GetTaskSession(ctx, "s1") + if session.WorkflowStepID == nil || *session.WorkflowStepID != "step1" { + t.Error("expected session to stay on step1") + } + }) + + t.Run("disable_plan_mode side-effect with transition", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + // Set plan_mode in session metadata + session, _ := repo.GetTaskSession(ctx, "s1") + session.Metadata = map[string]interface{}{"plan_mode": true} + _ = repo.UpdateTaskSession(ctx, session) + + stepGetter := newMockStepGetter() + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Step 1", Position: 0, + Events: wfmodels.StepEvents{ + OnTurnComplete: []wfmodels.OnTurnCompleteAction{ + {Type: wfmodels.OnTurnCompleteDisablePlanMode}, + {Type: wfmodels.OnTurnCompleteMoveToNext}, + }, + }, + } + stepGetter.steps["step2"] = &wfmodels.WorkflowStep{ + ID: "step2", WorkflowID: "wf1", Name: "Step 2", Position: 1, + Events: wfmodels.StepEvents{}, + } + + taskRepo := newMockTaskRepo() + svc := createTestService(repo, stepGetter, taskRepo) + got := svc.processOnTurnComplete(ctx, "t1", "s1") + if !got { + t.Error("expected true when transition occurs alongside disable_plan_mode") + } + + // Verify plan_mode was cleared + updatedSession, _ := repo.GetTaskSession(ctx, "s1") + if updatedSession.Metadata != nil { + if _, hasPlanMode := updatedSession.Metadata["plan_mode"]; hasPlanMode { + t.Error("expected plan_mode to be cleared from session metadata") + } + } + }) +} + +func TestProcessOnTurnStart(t *testing.T) { + ctx := context.Background() + + t.Run("no actions returns false", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + stepGetter := newMockStepGetter() + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Step 1", Position: 0, + Events: wfmodels.StepEvents{}, // no on_turn_start + } + + svc := createTestService(repo, stepGetter, newMockTaskRepo()) + got := svc.processOnTurnStart(ctx, "t1", "s1") + if got { + t.Error("expected false when step has no on_turn_start actions") + } + }) + + t.Run("move_to_next transitions", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + stepGetter := newMockStepGetter() + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Step 1", Position: 0, + Events: wfmodels.StepEvents{ + OnTurnStart: []wfmodels.OnTurnStartAction{ + {Type: wfmodels.OnTurnStartMoveToNext}, + }, + }, + } + stepGetter.steps["step2"] = &wfmodels.WorkflowStep{ + ID: "step2", WorkflowID: "wf1", Name: "Step 2", Position: 1, + Events: wfmodels.StepEvents{}, + } + + taskRepo := newMockTaskRepo() + svc := createTestService(repo, stepGetter, taskRepo) + got := svc.processOnTurnStart(ctx, "t1", "s1") + if !got { + t.Error("expected true when move_to_next transitions") + } + + session, _ := repo.GetTaskSession(ctx, "s1") + if session.WorkflowStepID == nil || *session.WorkflowStepID != "step2" { + t.Errorf("expected session workflow step to be 'step2', got %v", session.WorkflowStepID) + } + }) +} + +func TestProcessOnEnter(t *testing.T) { + ctx := context.Background() + + t.Run("enable_plan_mode sets plan mode", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + taskRepo := newMockTaskRepo() + svc := createTestService(repo, newMockStepGetter(), taskRepo) + + step := &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Plan Step", + Events: wfmodels.StepEvents{ + OnEnter: []wfmodels.OnEnterAction{ + {Type: wfmodels.OnEnterEnablePlanMode}, + }, + }, + } + + svc.processOnEnter(ctx, "t1", "s1", step, "test task") + + session, _ := repo.GetTaskSession(ctx, "s1") + if session.Metadata == nil { + t.Fatal("expected metadata to be set") + } + if pm, ok := session.Metadata["plan_mode"].(bool); !ok || !pm { + t.Error("expected plan_mode to be set to true in session metadata") + } + }) + + t.Run("no plan mode clears it", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + // Set plan_mode in session metadata + session, _ := repo.GetTaskSession(ctx, "s1") + session.Metadata = map[string]interface{}{"plan_mode": true} + _ = repo.UpdateTaskSession(ctx, session) + + taskRepo := newMockTaskRepo() + svc := createTestService(repo, newMockStepGetter(), taskRepo) + + step := &wfmodels.WorkflowStep{ + ID: "step1", Name: "Regular Step", + Events: wfmodels.StepEvents{}, // no enable_plan_mode + } + + svc.processOnEnter(ctx, "t1", "s1", step, "test task") + + updated, _ := repo.GetTaskSession(ctx, "s1") + if updated.Metadata != nil { + if _, hasPlanMode := updated.Metadata["plan_mode"]; hasPlanMode { + t.Error("expected plan_mode to be cleared from session metadata") + } + } + }) +} + +func TestSetSessionPlanMode(t *testing.T) { + ctx := context.Background() + + t.Run("enables plan mode", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.setSessionPlanMode(ctx, "s1", true) + + session, _ := repo.GetTaskSession(ctx, "s1") + if session.Metadata == nil { + t.Fatal("expected metadata to be set") + } + if pm, ok := session.Metadata["plan_mode"].(bool); !ok || !pm { + t.Error("expected plan_mode to be true") + } + }) + + t.Run("disables plan mode", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + // First enable + session, _ := repo.GetTaskSession(ctx, "s1") + session.Metadata = map[string]interface{}{"plan_mode": true} + _ = repo.UpdateTaskSession(ctx, session) + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.setSessionPlanMode(ctx, "s1", false) + + updated, _ := repo.GetTaskSession(ctx, "s1") + if updated.Metadata != nil { + if _, hasPlanMode := updated.Metadata["plan_mode"]; hasPlanMode { + t.Error("expected plan_mode to be removed from metadata") + } + } + }) + + t.Run("nil metadata gets initialized", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.setSessionPlanMode(ctx, "s1", true) + + session, _ := repo.GetTaskSession(ctx, "s1") + if session.Metadata == nil { + t.Fatal("expected metadata to be initialized") + } + if pm, ok := session.Metadata["plan_mode"].(bool); !ok || !pm { + t.Error("expected plan_mode to be true after initialization") + } + }) +} + diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 4dbc0ee33c..8c02005ce4 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -194,7 +194,7 @@ func (m *mockRepository) GetTask(ctx context.Context, id string) (*models.Task, } func (m *mockRepository) UpdateTask(ctx context.Context, task *models.Task) error { return nil } func (m *mockRepository) DeleteTask(ctx context.Context, id string) error { return nil } -func (m *mockRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) { +func (m *mockRepository) ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error) { return nil, nil } func (m *mockRepository) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int) ([]*models.Task, int, error) { @@ -203,10 +203,10 @@ func (m *mockRepository) ListTasksByWorkspace(ctx context.Context, workspaceID s func (m *mockRepository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) { return nil, nil } -func (m *mockRepository) AddTaskToBoard(ctx context.Context, taskID, boardID, workflowStepID string, position int) error { +func (m *mockRepository) AddTaskToWorkflow(ctx context.Context, taskID, workflowID, workflowStepID string, position int) error { return nil } -func (m *mockRepository) RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error { +func (m *mockRepository) RemoveTaskFromWorkflow(ctx context.Context, taskID, workflowID string) error { return nil } @@ -228,14 +228,18 @@ func (m *mockRepository) DeleteTaskRepositoriesByTask(ctx context.Context, taskI return nil } -// Board operations -func (m *mockRepository) CreateBoard(ctx context.Context, board *models.Board) error { return nil } -func (m *mockRepository) GetBoard(ctx context.Context, id string) (*models.Board, error) { +// Workflow operations +func (m *mockRepository) CreateWorkflow(ctx context.Context, workflow *models.Workflow) error { + return nil +} +func (m *mockRepository) GetWorkflow(ctx context.Context, id string) (*models.Workflow, error) { return nil, nil } -func (m *mockRepository) UpdateBoard(ctx context.Context, board *models.Board) error { return nil } -func (m *mockRepository) DeleteBoard(ctx context.Context, id string) error { return nil } -func (m *mockRepository) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) { +func (m *mockRepository) UpdateWorkflow(ctx context.Context, workflow *models.Workflow) error { + return nil +} +func (m *mockRepository) DeleteWorkflow(ctx context.Context, id string) error { return nil } +func (m *mockRepository) ListWorkflows(ctx context.Context, workspaceID string) ([]*models.Workflow, error) { return nil, nil } @@ -452,6 +456,12 @@ func (m *mockRepository) GetSessionFileReviews(ctx context.Context, sessionID st func (m *mockRepository) DeleteSessionFileReviews(ctx context.Context, sessionID string) error { return nil } +func (m *mockRepository) CountTasksByWorkflow(ctx context.Context, workflowID string) (int, error) { + return 0, nil +} +func (m *mockRepository) CountTasksByWorkflowStep(ctx context.Context, stepID string) (int, error) { + return 0, nil +} // Close operation func (m *mockRepository) Close() error { return nil } diff --git a/apps/backend/internal/orchestrator/queue/queue_test.go b/apps/backend/internal/orchestrator/queue/queue_test.go index 1f5336e015..ba6621f18e 100644 --- a/apps/backend/internal/orchestrator/queue/queue_test.go +++ b/apps/backend/internal/orchestrator/queue/queue_test.go @@ -11,7 +11,7 @@ import ( func createTestTask(id string, priority int) *v1.Task { return &v1.Task{ ID: id, - BoardID: "test-board", + WorkflowID: "test-wf", Title: "Test Task " + id, Priority: priority, State: v1.TaskStateTODO, diff --git a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go index 2b1c8ebde4..e6744368d6 100644 --- a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go +++ b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go @@ -155,7 +155,7 @@ func createTestExecutor(t *testing.T, agentMgr *mockAgentManager, log *logger.Lo func createTestTask(id string, priority int) *v1.Task { return &v1.Task{ ID: id, - BoardID: "test-board", + WorkflowID: "test-wf", Title: "Test Task " + id, Priority: priority, State: v1.TaskStateTODO, diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 75e344ec56..bfa0eab3d8 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -28,6 +28,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator/watcher" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" + wfmodels "github.com/kandev/kandev/internal/workflow/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -84,26 +85,11 @@ type TurnService interface { GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) } -// WorkflowStep contains workflow step data needed for prompt construction and transitions. -type WorkflowStep struct { - ID string - Name string - StepType string - AutoStartAgent bool - PlanMode bool - RequireApproval bool - PromptPrefix string - PromptSuffix string - OnCompleteStepID string // Step to transition to when agent completes - OnApprovalStepID string // Step to transition to when user approves -} - // WorkflowStepGetter retrieves workflow step information for prompt building. type WorkflowStepGetter interface { - GetStep(ctx context.Context, stepID string) (*WorkflowStep, error) - // GetSourceStep finds the step that has on_complete_step_id pointing to the given step. - // Used to find the "previous" step when moving back from a review step. - GetSourceStep(ctx context.Context, boardID, targetStepID string) (*WorkflowStep, error) + GetStep(ctx context.Context, stepID string) (*wfmodels.WorkflowStep, error) + GetNextStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) + GetPreviousStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) } // WorktreeRecreator handles worktree recreation when the directory is missing. diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 5490621422..68f09615b7 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -17,6 +17,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator/queue" "github.com/kandev/kandev/internal/sysprompt" "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -236,7 +237,7 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s "orchestrator", map[string]interface{}{ "task_id": dbTask.ID, - "board_id": dbTask.BoardID, + "workflow_id": dbTask.WorkflowID, "workflow_step_id": dbTask.WorkflowStepID, "title": dbTask.Title, "description": dbTask.Description, @@ -299,7 +300,7 @@ func (s *Service) applyWorkflowAndPlanMode(ctx context.Context, prompt string, t zap.String("workflow_step_id", workflowStepID), zap.Error(err)) } else { - stepHasPlanMode = step.PlanMode + stepHasPlanMode = step.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode) effectivePrompt = s.buildWorkflowPrompt(effectivePrompt, step, taskID) } } @@ -329,30 +330,33 @@ func (s *Service) recordInitialMessage(ctx context.Context, taskID, sessionID, p } // buildWorkflowPrompt constructs the effective prompt using workflow step configuration. -// It combines: step.PromptPrefix + basePrompt + step.PromptSuffix -// If step.PlanMode is true, it also prepends the plan mode prefix. -// Placeholders like {task_id} are interpolated with actual values. -// System-injected content (prefix, suffix, plan mode) is wrapped in tags -// so it can be stripped when displaying to users. -func (s *Service) buildWorkflowPrompt(basePrompt string, step *WorkflowStep, taskID string) string { +// If step.Prompt contains {{task_prompt}}, it is replaced with the base prompt. +// Otherwise, step.Prompt is prepended to the base prompt. +// If the step has enable_plan_mode in on_enter events, plan mode prefix is also prepended. +// System-injected content is wrapped in tags so it can be stripped when displaying to users. +func (s *Service) buildWorkflowPrompt(basePrompt string, step *wfmodels.WorkflowStep, taskID string) string { var parts []string // Apply plan mode prefix if enabled (wrapped in system tags) - if step.PlanMode { + if step.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode) { parts = append(parts, sysprompt.Wrap(sysprompt.PlanMode)) } - // Add prompt prefix if set (with interpolation, wrapped in system tags) - if step.PromptPrefix != "" { - parts = append(parts, sysprompt.Wrap(sysprompt.InterpolatePlaceholders(step.PromptPrefix, taskID))) - } - - // Add base prompt (user's actual message - not wrapped) - parts = append(parts, basePrompt) - - // Add prompt suffix if set (with interpolation, wrapped in system tags) - if step.PromptSuffix != "" { - parts = append(parts, sysprompt.Wrap(sysprompt.InterpolatePlaceholders(step.PromptSuffix, taskID))) + // Build the prompt from step.Prompt template and base prompt + if step.Prompt != "" { + interpolatedPrompt := sysprompt.InterpolatePlaceholders(step.Prompt, taskID) + if strings.Contains(interpolatedPrompt, "{{task_prompt}}") { + // Replace placeholder with base prompt + combined := strings.Replace(interpolatedPrompt, "{{task_prompt}}", basePrompt, 1) + parts = append(parts, combined) + } else { + // Prepend step prompt, then base prompt + parts = append(parts, sysprompt.Wrap(interpolatedPrompt)) + parts = append(parts, basePrompt) + } + } else { + // No step prompt, just use base prompt + parts = append(parts, basePrompt) } return strings.Join(parts, "\n\n") @@ -434,15 +438,10 @@ func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessi return fmt.Errorf("failed to get task: %w", err) } - // Check if session is in a review step with pending approval + // Check if session is in a step with pending approval // If so, reject the request - user must use Approve button or send a chat message if session.ReviewStatus != nil && *session.ReviewStatus == "pending" { - if session.WorkflowStepID != nil && *session.WorkflowStepID != "" { - currentStep, err := s.workflowStepGetter.GetStep(ctx, *session.WorkflowStepID) - if err == nil && currentStep.RequireApproval { - return fmt.Errorf("session is pending approval - use Approve button to proceed or send a message to request changes") - } - } + return fmt.Errorf("session is pending approval - use Approve button to proceed or send a message to request changes") } // Update session's workflow step to the new step @@ -497,10 +496,11 @@ func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessi s.logger.Debug("session resumed, now sending prompt") } - // Send the prompt using PromptTask (with planMode from step) + // Send the prompt using PromptTask (with planMode from step's on_enter events) // Note: PromptTask internally uses context.WithoutCancel for the executor call // No attachments for workflow-initiated prompts - _, err = s.PromptTask(ctx, taskID, sessionID, effectivePrompt, "", step.PlanMode, nil) + stepPlanMode := step.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode) + _, err = s.PromptTask(ctx, taskID, sessionID, effectivePrompt, "", stepPlanMode, nil) if err != nil { return fmt.Errorf("failed to prompt session: %w", err) } @@ -510,7 +510,7 @@ func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessi zap.String("session_id", sessionID), zap.String("workflow_step_id", workflowStepID), zap.String("step_name", step.Name), - zap.Bool("plan_mode", step.PlanMode)) + zap.Bool("plan_mode", stepPlanMode)) return nil } @@ -670,10 +670,6 @@ func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prom effectivePrompt = sysprompt.InjectPlanMode(prompt) } - // Check if session is in a review step - if so, move back to the previous step - // This handles the case where the user sends a message to iterate on the work - s.handleReviewStepRollback(ctx, taskID, sessionID) - // Check if model switching is requested if model != "" { // Get current model from agent profile snapshot (session already fetched above) diff --git a/apps/backend/internal/sysprompt/sysprompt.go b/apps/backend/internal/sysprompt/sysprompt.go index 783d22f8ff..325a5164da 100644 --- a/apps/backend/internal/sysprompt/sysprompt.go +++ b/apps/backend/internal/sysprompt/sysprompt.go @@ -68,9 +68,9 @@ Available tools: - update_task_plan_kandev: Update an existing plan. Required params: task_id, content (markdown). Optional: title. - delete_task_plan_kandev: Delete a task plan. Required params: task_id. - list_workspaces_kandev: List all workspaces. -- list_boards_kandev: List boards in a workspace. Required params: workspace_id. -- list_tasks_kandev: List tasks on a board. Required params: board_id. -- create_task_kandev: Create a new task. Required params: workspace_id, board_id, workflow_step_id, title. +- list_workflows_kandev: List workflows in a workspace. Required params: workspace_id. +- list_tasks_kandev: List tasks in a workflow. Required params: workflow_id. +- create_task_kandev: Create a new task. Required params: workspace_id, workflow_id, workflow_step_id, title. - update_task_kandev: Update a task. Required params: task_id. IMPORTANT: You MUST use these MCP tools when instructed to create plans, ask questions, or interact with the Kandev platform. Do not skip them.` diff --git a/apps/backend/internal/task/controller/board.go b/apps/backend/internal/task/controller/board.go index 78afb8429e..b0b429f899 100644 --- a/apps/backend/internal/task/controller/board.go +++ b/apps/backend/internal/task/controller/board.go @@ -1,245 +1 @@ package controller - -import ( - "context" - "fmt" - - "github.com/kandev/kandev/internal/task/dto" - "github.com/kandev/kandev/internal/task/models" - "github.com/kandev/kandev/internal/task/service" - workflowmodels "github.com/kandev/kandev/internal/workflow/models" -) - -// WorkflowStepLister provides access to workflow steps for boards with workflow templates. -// This allows the board controller to return workflow steps in snapshots. -type WorkflowStepLister interface { - ListStepsByBoard(ctx context.Context, boardID string) ([]*workflowmodels.WorkflowStep, error) -} - -type BoardController struct { - service *service.Service - workflowStepLister WorkflowStepLister -} - -func NewBoardController(svc *service.Service) *BoardController { - return &BoardController{service: svc} -} - -// SetWorkflowStepLister sets the workflow step lister for returning workflow steps. -func (c *BoardController) SetWorkflowStepLister(lister WorkflowStepLister) { - c.workflowStepLister = lister -} - -// toWorkflowStepDTO converts a workflow step to a WorkflowStepDTO. -func toWorkflowStepDTO(step *workflowmodels.WorkflowStep) dto.WorkflowStepDTO { - return dto.WorkflowStepDTO{ - ID: step.ID, - BoardID: step.BoardID, - Name: step.Name, - StepType: string(step.StepType), - Position: step.Position, - State: step.TaskState, - Color: step.Color, - AutoStartAgent: step.AutoStartAgent, - PlanMode: step.PlanMode, - RequireApproval: step.RequireApproval, - PromptPrefix: step.PromptPrefix, - PromptSuffix: step.PromptSuffix, - AllowManualMove: step.AllowManualMove, - CreatedAt: step.CreatedAt, - UpdatedAt: step.UpdatedAt, - } -} - -// getStepsForBoard returns workflow steps for a board. -func (c *BoardController) getStepsForBoard(ctx context.Context, board *models.Board) ([]dto.WorkflowStepDTO, error) { - if c.workflowStepLister == nil { - return nil, fmt.Errorf("workflow step lister not configured") - } - steps, err := c.workflowStepLister.ListStepsByBoard(ctx, board.ID) - if err != nil { - return nil, err - } - result := make([]dto.WorkflowStepDTO, 0, len(steps)) - for _, step := range steps { - result = append(result, toWorkflowStepDTO(step)) - } - return result, nil -} - -func (c *BoardController) ListBoards(ctx context.Context, req dto.ListBoardsRequest) (dto.ListBoardsResponse, error) { - boards, err := c.service.ListBoards(ctx, req.WorkspaceID) - if err != nil { - return dto.ListBoardsResponse{}, err - } - resp := dto.ListBoardsResponse{ - Boards: make([]dto.BoardDTO, 0, len(boards)), - Total: len(boards), - } - for _, board := range boards { - resp.Boards = append(resp.Boards, dto.FromBoard(board)) - } - return resp, nil -} - -func (c *BoardController) GetBoard(ctx context.Context, req dto.GetBoardRequest) (dto.BoardDTO, error) { - board, err := c.service.GetBoard(ctx, req.ID) - if err != nil { - return dto.BoardDTO{}, err - } - return dto.FromBoard(board), nil -} - -func (c *BoardController) CreateBoard(ctx context.Context, req dto.CreateBoardRequest) (dto.BoardDTO, error) { - board, err := c.service.CreateBoard(ctx, &service.CreateBoardRequest{ - WorkspaceID: req.WorkspaceID, - Name: req.Name, - Description: req.Description, - WorkflowTemplateID: req.WorkflowTemplateID, - }) - if err != nil { - return dto.BoardDTO{}, err - } - return dto.FromBoard(board), nil -} - -func (c *BoardController) UpdateBoard(ctx context.Context, req dto.UpdateBoardRequest) (dto.BoardDTO, error) { - board, err := c.service.UpdateBoard(ctx, req.ID, &service.UpdateBoardRequest{ - Name: req.Name, - Description: req.Description, - }) - if err != nil { - return dto.BoardDTO{}, err - } - return dto.FromBoard(board), nil -} - -func (c *BoardController) DeleteBoard(ctx context.Context, req dto.DeleteBoardRequest) (dto.SuccessResponse, error) { - if err := c.service.DeleteBoard(ctx, req.ID); err != nil { - return dto.SuccessResponse{}, err - } - return dto.SuccessResponse{Success: true}, nil -} - -func (c *BoardController) GetSnapshot(ctx context.Context, req dto.GetBoardSnapshotRequest) (dto.BoardSnapshotDTO, error) { - board, err := c.service.GetBoard(ctx, req.BoardID) - if err != nil { - return dto.BoardSnapshotDTO{}, err - } - steps, err := c.getStepsForBoard(ctx, board) - if err != nil { - return dto.BoardSnapshotDTO{}, err - } - tasks, err := c.service.ListTasks(ctx, req.BoardID) - if err != nil { - return dto.BoardSnapshotDTO{}, err - } - - // Fetch primary session IDs for all tasks in one query - taskDTOs, err := c.convertTasksWithPrimarySessions(ctx, tasks) - if err != nil { - return dto.BoardSnapshotDTO{}, err - } - - return dto.BoardSnapshotDTO{ - Board: dto.FromBoard(board), - Steps: steps, - Tasks: taskDTOs, - }, nil -} - -func (c *BoardController) GetWorkspaceSnapshot(ctx context.Context, req dto.GetWorkspaceBoardSnapshotRequest) (dto.BoardSnapshotDTO, error) { - boardID := req.BoardID - if boardID == "" { - boards, err := c.service.ListBoards(ctx, req.WorkspaceID) - if err != nil { - return dto.BoardSnapshotDTO{}, err - } - if len(boards) == 0 { - return dto.BoardSnapshotDTO{}, fmt.Errorf("no boards found for workspace: %s", req.WorkspaceID) - } - boardID = boards[0].ID - } - - board, err := c.service.GetBoard(ctx, boardID) - if err != nil { - return dto.BoardSnapshotDTO{}, err - } - if board.WorkspaceID != req.WorkspaceID { - return dto.BoardSnapshotDTO{}, fmt.Errorf("board does not belong to workspace: %s", req.WorkspaceID) - } - - steps, err := c.getStepsForBoard(ctx, board) - if err != nil { - return dto.BoardSnapshotDTO{}, err - } - tasks, err := c.service.ListTasks(ctx, boardID) - if err != nil { - return dto.BoardSnapshotDTO{}, err - } - - // Fetch primary session IDs for all tasks in one query - taskDTOs, err := c.convertTasksWithPrimarySessions(ctx, tasks) - if err != nil { - return dto.BoardSnapshotDTO{}, err - } - - return dto.BoardSnapshotDTO{ - Board: dto.FromBoard(board), - Steps: steps, - Tasks: taskDTOs, - }, nil -} - -// convertTasksWithPrimarySessions converts task models to DTOs with primary session IDs. -func (c *BoardController) convertTasksWithPrimarySessions(ctx context.Context, tasks []*models.Task) ([]dto.TaskDTO, error) { - if len(tasks) == 0 { - return []dto.TaskDTO{}, nil - } - - // Collect all task IDs - taskIDs := make([]string, len(tasks)) - for i, task := range tasks { - taskIDs[i] = task.ID - } - - // Fetch primary session IDs in bulk - primarySessionMap, err := c.service.GetPrimarySessionIDsForTasks(ctx, taskIDs) - if err != nil { - return nil, err - } - - // Fetch session counts in bulk - sessionCountMap, err := c.service.GetSessionCountsForTasks(ctx, taskIDs) - if err != nil { - return nil, err - } - - // Fetch primary session info (review status) in bulk - primarySessionInfoMap, err := c.service.GetPrimarySessionInfoForTasks(ctx, taskIDs) - if err != nil { - return nil, err - } - - // Convert tasks to DTOs with session information - result := make([]dto.TaskDTO, 0, len(tasks)) - for _, task := range tasks { - var primarySessionID *string - if sid, ok := primarySessionMap[task.ID]; ok { - primarySessionID = &sid - } - - var sessionCount *int - if count, ok := sessionCountMap[task.ID]; ok { - sessionCount = &count - } - - var reviewStatus *string - if sessionInfo, ok := primarySessionInfoMap[task.ID]; ok && sessionInfo.ReviewStatus != nil { - reviewStatus = sessionInfo.ReviewStatus - } - - result = append(result, dto.FromTaskWithSessionInfo(task, primarySessionID, sessionCount, reviewStatus)) - } - return result, nil -} diff --git a/apps/backend/internal/task/controller/task.go b/apps/backend/internal/task/controller/task.go index 9f0281aaa2..7742f07aa6 100644 --- a/apps/backend/internal/task/controller/task.go +++ b/apps/backend/internal/task/controller/task.go @@ -7,6 +7,7 @@ import ( "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/service" + wfmodels "github.com/kandev/kandev/internal/workflow/models" ) type TaskController struct { @@ -18,7 +19,7 @@ func NewTaskController(svc *service.Service) *TaskController { } func (c *TaskController) ListTasks(ctx context.Context, req dto.ListTasksRequest) (dto.ListTasksResponse, error) { - tasks, err := c.service.ListTasks(ctx, req.BoardID) + tasks, err := c.service.ListTasks(ctx, req.WorkflowID) if err != nil { return dto.ListTasksResponse{}, err } @@ -117,7 +118,7 @@ func (c *TaskController) CreateTask(ctx context.Context, req dto.CreateTaskReque task, err := c.service.CreateTask(ctx, &service.CreateTaskRequest{ WorkspaceID: req.WorkspaceID, - BoardID: req.BoardID, + WorkflowID: req.WorkflowID, WorkflowStepID: req.WorkflowStepID, Title: req.Title, Description: req.Description, @@ -178,7 +179,7 @@ func (c *TaskController) DeleteTask(ctx context.Context, req dto.DeleteTaskReque } func (c *TaskController) MoveTask(ctx context.Context, req dto.MoveTaskRequest) (dto.MoveTaskResponse, error) { - result, err := c.service.MoveTask(ctx, req.ID, req.BoardID, req.WorkflowStepID, req.Position) + result, err := c.service.MoveTask(ctx, req.ID, req.WorkflowID, req.WorkflowStepID, req.Position) if err != nil { return dto.MoveTaskResponse{}, err } @@ -189,20 +190,7 @@ func (c *TaskController) MoveTask(ctx context.Context, req dto.MoveTaskRequest) // Include workflow step info if available if result.WorkflowStep != nil { - response.WorkflowStep = dto.WorkflowStepDTO{ - ID: result.WorkflowStep.ID, - BoardID: result.WorkflowStep.BoardID, - Name: result.WorkflowStep.Name, - StepType: result.WorkflowStep.StepType, - Position: result.WorkflowStep.Position, - Color: result.WorkflowStep.Color, - AutoStartAgent: result.WorkflowStep.AutoStartAgent, - PlanMode: result.WorkflowStep.PlanMode, - RequireApproval: result.WorkflowStep.RequireApproval, - PromptPrefix: result.WorkflowStep.PromptPrefix, - PromptSuffix: result.WorkflowStep.PromptSuffix, - AllowManualMove: result.WorkflowStep.AllowManualMove, - } + response.WorkflowStep = serviceStepToDTO(result.WorkflowStep) } return response, nil @@ -216,6 +204,30 @@ func (c *TaskController) UpdateTaskState(ctx context.Context, req dto.UpdateTask return dto.FromTask(task), nil } +func (c *TaskController) CountTasksByWorkflow(ctx context.Context, workflowID string) (dto.TaskCountResponse, error) { + count, err := c.service.CountTasksByWorkflow(ctx, workflowID) + if err != nil { + return dto.TaskCountResponse{}, err + } + return dto.TaskCountResponse{TaskCount: count}, nil +} + +func (c *TaskController) CountTasksByWorkflowStep(ctx context.Context, stepID string) (dto.TaskCountResponse, error) { + count, err := c.service.CountTasksByWorkflowStep(ctx, stepID) + if err != nil { + return dto.TaskCountResponse{}, err + } + return dto.TaskCountResponse{TaskCount: count}, nil +} + +func (c *TaskController) BulkMoveTasks(ctx context.Context, req dto.BulkMoveTasksRequest) (dto.BulkMoveTasksResponse, error) { + result, err := c.service.BulkMoveTasks(ctx, req.SourceWorkflowID, req.SourceStepID, req.TargetWorkflowID, req.TargetStepID) + if err != nil { + return dto.BulkMoveTasksResponse{}, err + } + return dto.BulkMoveTasksResponse{MovedCount: result.MovedCount}, nil +} + // GetGitSnapshots retrieves git snapshots for a session func (c *TaskController) GetGitSnapshots(ctx context.Context, sessionID string, limit int) ([]*models.GitSnapshot, error) { return c.service.GetGitSnapshots(ctx, sessionID, limit) @@ -231,7 +243,7 @@ func (c *TaskController) GetCumulativeDiff(ctx context.Context, sessionID string return c.service.GetCumulativeDiff(ctx, sessionID) } -// ApproveSession approves a session's current step and moves it to the on_approval_step_id +// ApproveSession approves a session's current step and moves it to the next step func (c *TaskController) ApproveSession(ctx context.Context, req dto.ApproveSessionRequest) (dto.ApproveSessionResponse, error) { result, err := c.service.ApproveSession(ctx, req.SessionID) if err != nil { @@ -245,21 +257,38 @@ func (c *TaskController) ApproveSession(ctx context.Context, req dto.ApproveSess // Include the new workflow step if present if result.WorkflowStep != nil { - resp.WorkflowStep = dto.WorkflowStepDTO{ - ID: result.WorkflowStep.ID, - BoardID: result.WorkflowStep.BoardID, - Name: result.WorkflowStep.Name, - StepType: result.WorkflowStep.StepType, - Position: result.WorkflowStep.Position, - Color: result.WorkflowStep.Color, - AutoStartAgent: result.WorkflowStep.AutoStartAgent, - PlanMode: result.WorkflowStep.PlanMode, - RequireApproval: result.WorkflowStep.RequireApproval, - PromptPrefix: result.WorkflowStep.PromptPrefix, - PromptSuffix: result.WorkflowStep.PromptSuffix, - AllowManualMove: result.WorkflowStep.AllowManualMove, - } + resp.WorkflowStep = serviceStepToDTO(result.WorkflowStep) } return resp, nil } + +// serviceStepToDTO converts a workflow step to a DTO. +func serviceStepToDTO(step *wfmodels.WorkflowStep) dto.WorkflowStepDTO { + result := dto.WorkflowStepDTO{ + ID: step.ID, + WorkflowID: step.WorkflowID, + Name: step.Name, + Position: step.Position, + Color: step.Color, + Prompt: step.Prompt, + AllowManualMove: step.AllowManualMove, + } + if len(step.Events.OnEnter) > 0 || len(step.Events.OnTurnComplete) > 0 { + events := &dto.StepEventsDTO{} + for _, a := range step.Events.OnEnter { + events.OnEnter = append(events.OnEnter, dto.StepActionDTO{ + Type: string(a.Type), + Config: a.Config, + }) + } + for _, a := range step.Events.OnTurnComplete { + events.OnTurnComplete = append(events.OnTurnComplete, dto.StepActionDTO{ + Type: string(a.Type), + Config: a.Config, + }) + } + result.Events = events + } + return result +} diff --git a/apps/backend/internal/task/controller/workflow.go b/apps/backend/internal/task/controller/workflow.go new file mode 100644 index 0000000000..f011489a50 --- /dev/null +++ b/apps/backend/internal/task/controller/workflow.go @@ -0,0 +1,256 @@ +package controller + +import ( + "context" + "fmt" + + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" + workflowmodels "github.com/kandev/kandev/internal/workflow/models" +) + +// WorkflowStepLister provides access to workflow steps for workflows with workflow templates. +// This allows the workflow controller to return workflow steps in snapshots. +type WorkflowStepLister interface { + ListStepsByWorkflow(ctx context.Context, workflowID string) ([]*workflowmodels.WorkflowStep, error) +} + +type WorkflowController struct { + service *service.Service + workflowStepLister WorkflowStepLister +} + +func NewWorkflowController(svc *service.Service) *WorkflowController { + return &WorkflowController{service: svc} +} + +// SetWorkflowStepLister sets the workflow step lister for returning workflow steps. +func (c *WorkflowController) SetWorkflowStepLister(lister WorkflowStepLister) { + c.workflowStepLister = lister +} + +// toWorkflowStepDTO converts a workflow step to a WorkflowStepDTO. +func toWorkflowStepDTO(step *workflowmodels.WorkflowStep) dto.WorkflowStepDTO { + result := dto.WorkflowStepDTO{ + ID: step.ID, + WorkflowID: step.WorkflowID, + Name: step.Name, + Position: step.Position, + Color: step.Color, + Prompt: step.Prompt, + AllowManualMove: step.AllowManualMove, + CreatedAt: step.CreatedAt, + UpdatedAt: step.UpdatedAt, + } + if len(step.Events.OnEnter) > 0 || len(step.Events.OnTurnComplete) > 0 { + events := &dto.StepEventsDTO{} + for _, a := range step.Events.OnEnter { + events.OnEnter = append(events.OnEnter, dto.StepActionDTO{ + Type: string(a.Type), + Config: a.Config, + }) + } + for _, a := range step.Events.OnTurnComplete { + events.OnTurnComplete = append(events.OnTurnComplete, dto.StepActionDTO{ + Type: string(a.Type), + Config: a.Config, + }) + } + result.Events = events + } + return result +} + +// getStepsForWorkflow returns workflow steps for a workflow. +func (c *WorkflowController) getStepsForWorkflow(ctx context.Context, workflow *models.Workflow) ([]dto.WorkflowStepDTO, error) { + if c.workflowStepLister == nil { + return nil, fmt.Errorf("workflow step lister not configured") + } + steps, err := c.workflowStepLister.ListStepsByWorkflow(ctx, workflow.ID) + if err != nil { + return nil, err + } + result := make([]dto.WorkflowStepDTO, 0, len(steps)) + for _, step := range steps { + result = append(result, toWorkflowStepDTO(step)) + } + return result, nil +} + +func (c *WorkflowController) ListWorkflows(ctx context.Context, req dto.ListWorkflowsRequest) (dto.ListWorkflowsResponse, error) { + workflows, err := c.service.ListWorkflows(ctx, req.WorkspaceID) + if err != nil { + return dto.ListWorkflowsResponse{}, err + } + resp := dto.ListWorkflowsResponse{ + Workflows: make([]dto.WorkflowDTO, 0, len(workflows)), + Total: len(workflows), + } + for _, workflow := range workflows { + resp.Workflows = append(resp.Workflows, dto.FromWorkflow(workflow)) + } + return resp, nil +} + +func (c *WorkflowController) GetWorkflow(ctx context.Context, req dto.GetWorkflowRequest) (dto.WorkflowDTO, error) { + workflow, err := c.service.GetWorkflow(ctx, req.ID) + if err != nil { + return dto.WorkflowDTO{}, err + } + return dto.FromWorkflow(workflow), nil +} + +func (c *WorkflowController) CreateWorkflow(ctx context.Context, req dto.CreateWorkflowRequest) (dto.WorkflowDTO, error) { + workflow, err := c.service.CreateWorkflow(ctx, &service.CreateWorkflowRequest{ + WorkspaceID: req.WorkspaceID, + Name: req.Name, + Description: req.Description, + WorkflowTemplateID: req.WorkflowTemplateID, + }) + if err != nil { + return dto.WorkflowDTO{}, err + } + return dto.FromWorkflow(workflow), nil +} + +func (c *WorkflowController) UpdateWorkflow(ctx context.Context, req dto.UpdateWorkflowRequest) (dto.WorkflowDTO, error) { + workflow, err := c.service.UpdateWorkflow(ctx, req.ID, &service.UpdateWorkflowRequest{ + Name: req.Name, + Description: req.Description, + }) + if err != nil { + return dto.WorkflowDTO{}, err + } + return dto.FromWorkflow(workflow), nil +} + +func (c *WorkflowController) DeleteWorkflow(ctx context.Context, req dto.DeleteWorkflowRequest) (dto.SuccessResponse, error) { + if err := c.service.DeleteWorkflow(ctx, req.ID); err != nil { + return dto.SuccessResponse{}, err + } + return dto.SuccessResponse{Success: true}, nil +} + +func (c *WorkflowController) GetSnapshot(ctx context.Context, req dto.GetWorkflowSnapshotRequest) (dto.WorkflowSnapshotDTO, error) { + workflow, err := c.service.GetWorkflow(ctx, req.WorkflowID) + if err != nil { + return dto.WorkflowSnapshotDTO{}, err + } + steps, err := c.getStepsForWorkflow(ctx, workflow) + if err != nil { + return dto.WorkflowSnapshotDTO{}, err + } + tasks, err := c.service.ListTasks(ctx, req.WorkflowID) + if err != nil { + return dto.WorkflowSnapshotDTO{}, err + } + + // Fetch primary session IDs for all tasks in one query + taskDTOs, err := c.convertTasksWithPrimarySessions(ctx, tasks) + if err != nil { + return dto.WorkflowSnapshotDTO{}, err + } + + return dto.WorkflowSnapshotDTO{ + Workflow: dto.FromWorkflow(workflow), + Steps: steps, + Tasks: taskDTOs, + }, nil +} + +func (c *WorkflowController) GetWorkspaceSnapshot(ctx context.Context, req dto.GetWorkspaceSnapshotRequest) (dto.WorkflowSnapshotDTO, error) { + workflowID := req.WorkflowID + if workflowID == "" { + workflows, err := c.service.ListWorkflows(ctx, req.WorkspaceID) + if err != nil { + return dto.WorkflowSnapshotDTO{}, err + } + if len(workflows) == 0 { + return dto.WorkflowSnapshotDTO{}, fmt.Errorf("no workflows found for workspace: %s", req.WorkspaceID) + } + workflowID = workflows[0].ID + } + + workflow, err := c.service.GetWorkflow(ctx, workflowID) + if err != nil { + return dto.WorkflowSnapshotDTO{}, err + } + if workflow.WorkspaceID != req.WorkspaceID { + return dto.WorkflowSnapshotDTO{}, fmt.Errorf("workflow does not belong to workspace: %s", req.WorkspaceID) + } + + steps, err := c.getStepsForWorkflow(ctx, workflow) + if err != nil { + return dto.WorkflowSnapshotDTO{}, err + } + tasks, err := c.service.ListTasks(ctx, workflowID) + if err != nil { + return dto.WorkflowSnapshotDTO{}, err + } + + // Fetch primary session IDs for all tasks in one query + taskDTOs, err := c.convertTasksWithPrimarySessions(ctx, tasks) + if err != nil { + return dto.WorkflowSnapshotDTO{}, err + } + + return dto.WorkflowSnapshotDTO{ + Workflow: dto.FromWorkflow(workflow), + Steps: steps, + Tasks: taskDTOs, + }, nil +} + +// convertTasksWithPrimarySessions converts task models to DTOs with primary session IDs. +func (c *WorkflowController) convertTasksWithPrimarySessions(ctx context.Context, tasks []*models.Task) ([]dto.TaskDTO, error) { + if len(tasks) == 0 { + return []dto.TaskDTO{}, nil + } + + // Collect all task IDs + taskIDs := make([]string, len(tasks)) + for i, task := range tasks { + taskIDs[i] = task.ID + } + + // Fetch primary session IDs in bulk + primarySessionMap, err := c.service.GetPrimarySessionIDsForTasks(ctx, taskIDs) + if err != nil { + return nil, err + } + + // Fetch session counts in bulk + sessionCountMap, err := c.service.GetSessionCountsForTasks(ctx, taskIDs) + if err != nil { + return nil, err + } + + // Fetch primary session info (review status) in bulk + primarySessionInfoMap, err := c.service.GetPrimarySessionInfoForTasks(ctx, taskIDs) + if err != nil { + return nil, err + } + + // Convert tasks to DTOs with session information + result := make([]dto.TaskDTO, 0, len(tasks)) + for _, task := range tasks { + var primarySessionID *string + if sid, ok := primarySessionMap[task.ID]; ok { + primarySessionID = &sid + } + + var sessionCount *int + if count, ok := sessionCountMap[task.ID]; ok { + sessionCount = &count + } + + var reviewStatus *string + if sessionInfo, ok := primarySessionInfoMap[task.ID]; ok && sessionInfo.ReviewStatus != nil { + reviewStatus = sessionInfo.ReviewStatus + } + + result = append(result, dto.FromTaskWithSessionInfo(task, primarySessionID, sessionCount, reviewStatus)) + } + return result, nil +} diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index ade672fd67..1a6e7038d7 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -8,7 +8,7 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" ) -type BoardDTO struct { +type WorkflowDTO struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` Name string `json:"name"` @@ -88,7 +88,7 @@ type EnvironmentDTO struct { type TaskDTO struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` + WorkflowID string `json:"workflow_id"` WorkflowStepID string `json:"workflow_step_id"` Title string `json:"title"` Description string `json:"description"` @@ -153,10 +153,10 @@ type ListTaskSessionsResponse struct { Total int `json:"total"` } -type BoardSnapshotDTO struct { - Board BoardDTO `json:"board"` - Steps []WorkflowStepDTO `json:"steps"` - Tasks []TaskDTO `json:"tasks"` +type WorkflowSnapshotDTO struct { + Workflow WorkflowDTO `json:"workflow"` + Steps []WorkflowStepDTO `json:"steps"` + Tasks []TaskDTO `json:"tasks"` } type ListMessagesResponse struct { @@ -182,9 +182,9 @@ type ListTurnsResponse struct { Total int `json:"total"` } -type ListBoardsResponse struct { - Boards []BoardDTO `json:"boards"` - Total int `json:"total"` +type ListWorkflowsResponse struct { + Workflows []WorkflowDTO `json:"workflows"` + Total int `json:"total"` } type ListWorkspacesResponse struct { @@ -253,19 +253,19 @@ type SuccessResponse struct { Success bool `json:"success"` } -func FromBoard(board *models.Board) BoardDTO { +func FromWorkflow(workflow *models.Workflow) WorkflowDTO { var description *string - if board.Description != "" { - description = &board.Description + if workflow.Description != "" { + description = &workflow.Description } - return BoardDTO{ - ID: board.ID, - WorkspaceID: board.WorkspaceID, - Name: board.Name, + return WorkflowDTO{ + ID: workflow.ID, + WorkspaceID: workflow.WorkspaceID, + Name: workflow.Name, Description: description, - CreatedAt: board.CreatedAt, - UpdatedAt: board.UpdatedAt, + CreatedAt: workflow.CreatedAt, + UpdatedAt: workflow.UpdatedAt, } } @@ -388,7 +388,7 @@ func FromTaskWithSessionInfo(task *models.Task, primarySessionID *string, sessio return TaskDTO{ ID: task.ID, WorkspaceID: task.WorkspaceID, - BoardID: task.BoardID, + WorkflowID: task.WorkflowID, WorkflowStepID: task.WorkflowStepID, Title: task.Title, Description: task.Description, @@ -441,21 +441,28 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO { // WorkflowStepDTO represents a workflow step for API responses type WorkflowStepDTO struct { - ID string `json:"id"` - BoardID string `json:"board_id"` - Name string `json:"name"` - StepType string `json:"step_type"` - Position int `json:"position"` - State v1.TaskState `json:"state"` - Color string `json:"color"` - AutoStartAgent bool `json:"auto_start_agent"` - PlanMode bool `json:"plan_mode"` - RequireApproval bool `json:"require_approval"` - PromptPrefix string `json:"prompt_prefix,omitempty"` - PromptSuffix string `json:"prompt_suffix,omitempty"` - AllowManualMove bool `json:"allow_manual_move"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + WorkflowID string `json:"workflow_id"` + Name string `json:"name"` + Position int `json:"position"` + Color string `json:"color"` + Prompt string `json:"prompt,omitempty"` + Events *StepEventsDTO `json:"events,omitempty"` + AllowManualMove bool `json:"allow_manual_move"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// StepEventsDTO represents step events for API responses +type StepEventsDTO struct { + OnEnter []StepActionDTO `json:"on_enter,omitempty"` + OnTurnComplete []StepActionDTO `json:"on_turn_complete,omitempty"` +} + +// StepActionDTO represents a step action for API responses +type StepActionDTO struct { + Type string `json:"type"` + Config map[string]interface{} `json:"config,omitempty"` } // MoveTaskResponse includes the task and the target workflow step info diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index c220b798cb..143def7f65 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -5,7 +5,7 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" ) -type ListBoardsRequest struct { +type ListWorkflowsRequest struct { WorkspaceID string } @@ -37,24 +37,24 @@ type DeleteWorkspaceRequest struct { ID string } -type GetBoardRequest struct { +type GetWorkflowRequest struct { ID string } -type CreateBoardRequest struct { +type CreateWorkflowRequest struct { WorkspaceID string Name string Description string WorkflowTemplateID *string } -type UpdateBoardRequest struct { +type UpdateWorkflowRequest struct { ID string Name *string Description *string } -type DeleteBoardRequest struct { +type DeleteWorkflowRequest struct { ID string } @@ -207,17 +207,17 @@ type ValidateRepositoryPathRequest struct { Path string } -type GetBoardSnapshotRequest struct { - BoardID string +type GetWorkflowSnapshotRequest struct { + WorkflowID string } -type GetWorkspaceBoardSnapshotRequest struct { +type GetWorkspaceSnapshotRequest struct { WorkspaceID string - BoardID string + WorkflowID string } type ListTasksRequest struct { - BoardID string + WorkflowID string } type ListTasksByWorkspaceRequest struct { @@ -249,7 +249,7 @@ type TaskRepositoryInput struct { type CreateTaskRequest struct { WorkspaceID string - BoardID string + WorkflowID string WorkflowStepID string Title string Description string @@ -296,7 +296,7 @@ type CreateMessageRequest struct { type MoveTaskRequest struct { ID string - BoardID string + WorkflowID string WorkflowStepID string Position int } @@ -305,3 +305,18 @@ type UpdateTaskStateRequest struct { ID string State v1.TaskState } + +type BulkMoveTasksRequest struct { + SourceWorkflowID string `json:"source_workflow_id"` + SourceStepID string `json:"source_step_id,omitempty"` + TargetWorkflowID string `json:"target_workflow_id"` + TargetStepID string `json:"target_step_id"` +} + +type BulkMoveTasksResponse struct { + MovedCount int `json:"moved_count"` +} + +type TaskCountResponse struct { + TaskCount int `json:"task_count"` +} diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index c6cb8a75b6..e27c21de98 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -27,6 +27,7 @@ type OrchestratorService interface { PromptTask(ctx context.Context, taskID, sessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment) (*orchestrator.PromptResult, error) ResumeTaskSession(ctx context.Context, taskID, taskSessionID string) error StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string) error + ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error } // MessageHandlers handles WebSocket requests for messages @@ -217,6 +218,17 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w // Use context.WithoutCancel so the prompt continues even if the WebSocket client disconnects. // The user's message is already saved, and agent responses are broadcast via notifications. if h.orchestrator != nil { + // Process on_turn_start events synchronously BEFORE sending the prompt. + // This transitions the task to the right step (e.g., Review → In Progress) + // before the agent receives the message. + if !isCreatedSession { + if err := h.orchestrator.ProcessOnTurnStart(ctx, req.TaskID, req.TaskSessionID); err != nil { + h.logger.Warn("failed to process on_turn_start", + zap.String("task_id", req.TaskID), + zap.String("session_id", req.TaskSessionID), + zap.Error(err)) + } + } taskID := req.TaskID sessionID := req.TaskSessionID content := req.Content diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 1f37ad1c2c..97370738c8 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -57,7 +57,7 @@ func (m *mockRepository) UpdateTask(ctx context.Context, task *models.Task) erro func (m *mockRepository) DeleteTask(ctx context.Context, id string) error { return nil } -func (m *mockRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) { +func (m *mockRepository) ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error) { return nil, nil } func (m *mockRepository) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int) ([]*models.Task, int, error) { @@ -69,10 +69,10 @@ func (m *mockRepository) ListTasksByWorkflowStep(ctx context.Context, workflowSt func (m *mockRepository) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error { return nil } -func (m *mockRepository) AddTaskToBoard(ctx context.Context, taskID, boardID, columnID string, position int) error { +func (m *mockRepository) AddTaskToWorkflow(ctx context.Context, taskID, workflowID, columnID string, position int) error { return nil } -func (m *mockRepository) RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error { +func (m *mockRepository) RemoveTaskFromWorkflow(ctx context.Context, taskID, workflowID string) error { return nil } func (m *mockRepository) CreateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error { @@ -96,19 +96,19 @@ func (m *mockRepository) DeleteTaskRepositoriesByTask(ctx context.Context, taskI func (m *mockRepository) GetPrimaryTaskRepository(ctx context.Context, taskID string) (*models.TaskRepository, error) { return nil, nil } -func (m *mockRepository) CreateBoard(ctx context.Context, board *models.Board) error { +func (m *mockRepository) CreateWorkflow(ctx context.Context, workflow *models.Workflow) error { return nil } -func (m *mockRepository) GetBoard(ctx context.Context, id string) (*models.Board, error) { +func (m *mockRepository) GetWorkflow(ctx context.Context, id string) (*models.Workflow, error) { return nil, nil } -func (m *mockRepository) UpdateBoard(ctx context.Context, board *models.Board) error { +func (m *mockRepository) UpdateWorkflow(ctx context.Context, workflow *models.Workflow) error { return nil } -func (m *mockRepository) DeleteBoard(ctx context.Context, id string) error { +func (m *mockRepository) DeleteWorkflow(ctx context.Context, id string) error { return nil } -func (m *mockRepository) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) { +func (m *mockRepository) ListWorkflows(ctx context.Context, workspaceID string) ([]*models.Workflow, error) { return nil, nil } func (m *mockRepository) CreateMessage(ctx context.Context, message *models.Message) error { @@ -376,6 +376,12 @@ func (m *mockRepository) GetSessionFileReviews(ctx context.Context, sessionID st func (m *mockRepository) DeleteSessionFileReviews(ctx context.Context, sessionID string) error { return nil } +func (m *mockRepository) CountTasksByWorkflow(ctx context.Context, workflowID string) (int, error) { + return 0, nil +} +func (m *mockRepository) CountTasksByWorkflowStep(ctx context.Context, stepID string) (int, error) { + return 0, nil +} func newTestService(t *testing.T, scripts map[string][]*models.RepositoryScript) *service.Service { log, err := logger.NewLogger(logger.LoggingConfig{ diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 17d2c66ef0..c27f4509e8 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -62,7 +62,7 @@ func RegisterTaskRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *con func (h *TaskHandlers) registerHTTP(router *gin.Engine) { api := router.Group("/api/v1") - api.GET("/boards/:id/tasks", h.httpListTasks) + api.GET("/workflows/:id/tasks", h.httpListTasks) api.GET("/workspaces/:id/tasks", h.httpListTasksByWorkspace) api.GET("/tasks/:id", h.httpGetTask) api.GET("/task-sessions/:id", h.httpGetTaskSession) @@ -73,6 +73,10 @@ func (h *TaskHandlers) registerHTTP(router *gin.Engine) { api.POST("/tasks/:id/move", h.httpMoveTask) api.DELETE("/tasks/:id", h.httpDeleteTask) + api.POST("/tasks/bulk-move", h.httpBulkMoveTasks) + api.GET("/workflows/:id/task-count", h.httpGetWorkflowTaskCount) + api.GET("/workflow/steps/:id/task-count", h.httpGetStepTaskCount) + // Session workflow review endpoints api.POST("/sessions/:id/approve", h.httpApproveSession) } @@ -104,7 +108,7 @@ func (h *TaskHandlers) registerWS(dispatcher *ws.Dispatcher) { // HTTP handlers func (h *TaskHandlers) httpListTasks(c *gin.Context) { - resp, err := h.controller.ListTasks(c.Request.Context(), dto.ListTasksRequest{BoardID: c.Param("id")}) + resp, err := h.controller.ListTasks(c.Request.Context(), dto.ListTasksRequest{WorkflowID: c.Param("id")}) if err != nil { handleNotFound(c, h.logger, err, "tasks not found") return @@ -208,6 +212,57 @@ func (h *TaskHandlers) httpApproveSession(c *gin.Context) { c.JSON(http.StatusOK, resp) } +func (h *TaskHandlers) httpGetWorkflowTaskCount(c *gin.Context) { + resp, err := h.controller.CountTasksByWorkflow(c.Request.Context(), c.Param("id")) + if err != nil { + h.logger.Error("failed to count tasks by workflow", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count tasks"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *TaskHandlers) httpGetStepTaskCount(c *gin.Context) { + resp, err := h.controller.CountTasksByWorkflowStep(c.Request.Context(), c.Param("id")) + if err != nil { + h.logger.Error("failed to count tasks by step", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count tasks"}) + return + } + c.JSON(http.StatusOK, resp) +} + +type httpBulkMoveTasksRequest struct { + SourceWorkflowID string `json:"source_workflow_id"` + SourceStepID string `json:"source_step_id,omitempty"` + TargetWorkflowID string `json:"target_workflow_id"` + TargetStepID string `json:"target_step_id"` +} + +func (h *TaskHandlers) httpBulkMoveTasks(c *gin.Context) { + var body httpBulkMoveTasksRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if body.SourceWorkflowID == "" || body.TargetWorkflowID == "" || body.TargetStepID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "source_workflow_id, target_workflow_id, and target_step_id are required"}) + return + } + resp, err := h.controller.BulkMoveTasks(c.Request.Context(), dto.BulkMoveTasksRequest{ + SourceWorkflowID: body.SourceWorkflowID, + SourceStepID: body.SourceStepID, + TargetWorkflowID: body.TargetWorkflowID, + TargetStepID: body.TargetStepID, + }) + if err != nil { + h.logger.Error("failed to bulk move tasks", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk move tasks"}) + return + } + c.JSON(http.StatusOK, resp) +} + type httpTaskRepositoryInput struct { RepositoryID string `json:"repository_id"` BaseBranch string `json:"base_branch"` @@ -218,7 +273,7 @@ type httpTaskRepositoryInput struct { type httpCreateTaskRequest struct { WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` + WorkflowID string `json:"workflow_id"` WorkflowStepID string `json:"workflow_step_id"` Title string `json:"title"` Description string `json:"description,omitempty"` @@ -274,7 +329,7 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { reqCtx := c.Request.Context() resp, err := h.controller.CreateTask(reqCtx, dto.CreateTaskRequest{ WorkspaceID: body.WorkspaceID, - BoardID: body.BoardID, + WorkflowID: body.WorkflowID, WorkflowStepID: body.WorkflowStepID, Title: body.Title, Description: body.Description, @@ -290,10 +345,12 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { } response := createTaskResponse{TaskDTO: resp} + // Use the backend-resolved workflow step ID (from the created task) instead of the request's + resolvedStepID := resp.WorkflowStepID if body.PrepareSession && !body.StartAgent && body.AgentProfileID != "" && h.orchestrator != nil { // Create session entry without launching the agent. // The session stays in CREATED state until the user triggers it. - sessionID, err := h.orchestrator.PrepareTaskSession(reqCtx, resp.ID, body.AgentProfileID, body.ExecutorID, body.WorkflowStepID) + sessionID, err := h.orchestrator.PrepareTaskSession(reqCtx, resp.ID, body.AgentProfileID, body.ExecutorID, resolvedStepID) if err != nil { h.logger.Error("failed to prepare session for task", zap.Error(err), zap.String("task_id", resp.ID)) } else { @@ -301,7 +358,7 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { } } else if body.StartAgent && body.AgentProfileID != "" && h.orchestrator != nil { // Create session entry synchronously so we can return the session ID immediately - sessionID, err := h.orchestrator.PrepareTaskSession(reqCtx, resp.ID, body.AgentProfileID, body.ExecutorID, body.WorkflowStepID) + sessionID, err := h.orchestrator.PrepareTaskSession(reqCtx, resp.ID, body.AgentProfileID, body.ExecutorID, resolvedStepID) if err != nil { h.logger.Error("failed to prepare session for task", zap.Error(err), zap.String("task_id", resp.ID)) // Continue without session - task was created successfully @@ -310,14 +367,14 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { // Launch agent asynchronously so the HTTP request can return immediately. // The frontend will receive WebSocket updates when the agent actually starts. - executorID := body.ExecutorID // Capture for goroutine - workflowStepID := body.WorkflowStepID // Capture for goroutine + executorID := body.ExecutorID // Capture for goroutine + stepID := resolvedStepID // Capture for goroutine go func() { // Use a longer timeout to accommodate setup scripts (which can take minutes for npm install, etc.) startCtx, cancel := context.WithTimeout(context.Background(), constants.AgentLaunchTimeout) defer cancel() // Use task description as the initial prompt with workflow step config (prompt prefix/suffix, plan mode) - execution, err := h.orchestrator.StartTaskWithSession(startCtx, resp.ID, sessionID, body.AgentProfileID, executorID, body.Priority, resp.Description, workflowStepID, body.PlanMode) + execution, err := h.orchestrator.StartTaskWithSession(startCtx, resp.ID, sessionID, body.AgentProfileID, executorID, body.Priority, resp.Description, stepID, body.PlanMode) if err != nil { h.logger.Error("failed to start agent for task (async)", zap.Error(err), zap.String("task_id", resp.ID), zap.String("session_id", sessionID)) return @@ -326,7 +383,7 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { zap.String("task_id", resp.ID), zap.String("session_id", execution.SessionID), zap.String("executor_id", executorID), - zap.String("workflow_step_id", workflowStepID), + zap.String("workflow_step_id", stepID), zap.String("execution_id", execution.AgentExecutionID)) }() } @@ -384,7 +441,7 @@ func (h *TaskHandlers) httpUpdateTask(c *gin.Context) { } type httpMoveTaskRequest struct { - BoardID string `json:"board_id"` + WorkflowID string `json:"workflow_id"` WorkflowStepID string `json:"workflow_step_id"` Position int `json:"position"` } @@ -395,13 +452,13 @@ func (h *TaskHandlers) httpMoveTask(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - if body.BoardID == "" || body.WorkflowStepID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "board_id and workflow_step_id are required"}) + if body.WorkflowID == "" || body.WorkflowStepID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "workflow_id and workflow_step_id are required"}) return } resp, err := h.controller.MoveTask(c.Request.Context(), dto.MoveTaskRequest{ ID: c.Param("id"), - BoardID: body.BoardID, + WorkflowID: body.WorkflowID, WorkflowStepID: body.WorkflowStepID, Position: body.Position, }) @@ -447,7 +504,7 @@ func (h *TaskHandlers) wsListTaskSessions(ctx context.Context, msg *ws.Message) } type wsListTasksRequest struct { - BoardID string `json:"board_id"` + WorkflowID string `json:"workflow_id"` } func (h *TaskHandlers) wsListTasks(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -455,11 +512,11 @@ func (h *TaskHandlers) wsListTasks(ctx context.Context, msg *ws.Message) (*ws.Me if err := msg.ParsePayload(&req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } - if req.BoardID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil) + if req.WorkflowID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) } - resp, err := h.controller.ListTasks(ctx, dto.ListTasksRequest{BoardID: req.BoardID}) + resp, err := h.controller.ListTasks(ctx, dto.ListTasksRequest{WorkflowID: req.WorkflowID}) if err != nil { h.logger.Error("failed to list tasks", zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list tasks", nil) @@ -469,7 +526,7 @@ func (h *TaskHandlers) wsListTasks(ctx context.Context, msg *ws.Message) (*ws.Me type wsCreateTaskRequest struct { WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` + WorkflowID string `json:"workflow_id"` WorkflowStepID string `json:"workflow_step_id"` Title string `json:"title"` Description string `json:"description,omitempty"` @@ -492,11 +549,8 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M if req.WorkspaceID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_id is required", nil) } - if req.BoardID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil) - } - if req.WorkflowStepID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_step_id is required", nil) + if req.WorkflowID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) } if req.Title == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil) @@ -522,7 +576,7 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M resp, err := h.controller.CreateTask(ctx, dto.CreateTaskRequest{ WorkspaceID: req.WorkspaceID, - BoardID: req.BoardID, + WorkflowID: req.WorkflowID, WorkflowStepID: req.WorkflowStepID, Title: req.Title, Description: req.Description, @@ -540,7 +594,8 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M response := createTaskResponse{TaskDTO: resp} if req.StartAgent && req.AgentProfileID != "" && h.orchestrator != nil { // Use task description as the initial prompt with workflow step config (prompt prefix/suffix, plan mode) - execution, err := h.orchestrator.StartTask(ctx, resp.ID, req.AgentProfileID, req.ExecutorID, req.Priority, resp.Description, req.WorkflowStepID, req.PlanMode) + // Use resp.WorkflowStepID (backend-resolved) instead of req.WorkflowStepID + execution, err := h.orchestrator.StartTask(ctx, resp.ID, req.AgentProfileID, req.ExecutorID, req.Priority, resp.Description, resp.WorkflowStepID, req.PlanMode) if err != nil { h.logger.Error("failed to start agent for task", zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to start agent for task", nil) @@ -650,7 +705,7 @@ func (h *TaskHandlers) wsDeleteTask(ctx context.Context, msg *ws.Message) (*ws.M type wsMoveTaskRequest struct { ID string `json:"id"` - BoardID string `json:"board_id"` + WorkflowID string `json:"workflow_id"` WorkflowStepID string `json:"workflow_step_id"` Position int `json:"position"` } @@ -663,8 +718,8 @@ func (h *TaskHandlers) wsMoveTask(ctx context.Context, msg *ws.Message) (*ws.Mes if req.ID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) } - if req.BoardID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil) + if req.WorkflowID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) } if req.WorkflowStepID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_step_id is required", nil) @@ -672,7 +727,7 @@ func (h *TaskHandlers) wsMoveTask(ctx context.Context, msg *ws.Message) (*ws.Mes resp, err := h.controller.MoveTask(ctx, dto.MoveTaskRequest{ ID: req.ID, - BoardID: req.BoardID, + WorkflowID: req.WorkflowID, WorkflowStepID: req.WorkflowStepID, Position: req.Position, }) diff --git a/apps/backend/internal/task/handlers/board_handlers.go b/apps/backend/internal/task/handlers/workflow_handlers.go similarity index 52% rename from apps/backend/internal/task/handlers/board_handlers.go rename to apps/backend/internal/task/handlers/workflow_handlers.go index fa28de0d9b..659d53a27e 100644 --- a/apps/backend/internal/task/handlers/board_handlers.go +++ b/apps/backend/internal/task/handlers/workflow_handlers.go @@ -13,90 +13,88 @@ import ( "go.uber.org/zap" ) -type BoardHandlers struct { - controller *controller.BoardController +type WorkflowHandlers struct { + controller *controller.WorkflowController logger *logger.Logger } -func NewBoardHandlers(svc *controller.BoardController, log *logger.Logger) *BoardHandlers { - return &BoardHandlers{ +func NewWorkflowHandlers(svc *controller.WorkflowController, log *logger.Logger) *WorkflowHandlers { + return &WorkflowHandlers{ controller: svc, - logger: log.WithFields(zap.String("component", "task-board-handlers")), + logger: log.WithFields(zap.String("component", "task-workflow-handlers")), } } -func RegisterBoardRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *controller.BoardController, log *logger.Logger) { - handlers := NewBoardHandlers(ctrl, log) +func RegisterWorkflowRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *controller.WorkflowController, log *logger.Logger) { + handlers := NewWorkflowHandlers(ctrl, log) handlers.registerHTTP(router) handlers.registerWS(dispatcher) } -func (h *BoardHandlers) registerHTTP(router *gin.Engine) { +func (h *WorkflowHandlers) registerHTTP(router *gin.Engine) { api := router.Group("/api/v1") - api.GET("/boards", h.httpListBoards) - api.GET("/workspaces/:id/boards", h.httpListBoardsByWorkspace) - api.GET("/workspaces/:id/board-snapshot", h.httpGetWorkspaceBoardSnapshot) - api.GET("/boards/:id", h.httpGetBoard) - api.GET("/boards/:id/snapshot", h.httpGetBoardSnapshot) - api.POST("/boards", h.httpCreateBoard) - api.PATCH("/boards/:id", h.httpUpdateBoard) - api.DELETE("/boards/:id", h.httpDeleteBoard) - // Column endpoints removed - use workflow steps instead + api.GET("/workflows", h.httpListWorkflows) + api.GET("/workspaces/:id/workflows", h.httpListWorkflowsByWorkspace) + api.GET("/workspaces/:id/snapshot", h.httpGetWorkspaceSnapshot) + api.GET("/workflows/:id", h.httpGetWorkflow) + api.GET("/workflows/:id/snapshot", h.httpGetWorkflowSnapshot) + api.POST("/workflows", h.httpCreateWorkflow) + api.PATCH("/workflows/:id", h.httpUpdateWorkflow) + api.DELETE("/workflows/:id", h.httpDeleteWorkflow) } -func (h *BoardHandlers) registerWS(dispatcher *ws.Dispatcher) { - dispatcher.RegisterFunc(ws.ActionBoardList, h.wsListBoards) - dispatcher.RegisterFunc(ws.ActionBoardCreate, h.wsCreateBoard) - dispatcher.RegisterFunc(ws.ActionBoardGet, h.wsGetBoard) - dispatcher.RegisterFunc(ws.ActionBoardUpdate, h.wsUpdateBoard) - dispatcher.RegisterFunc(ws.ActionBoardDelete, h.wsDeleteBoard) - // Column WS actions removed - use workflow steps instead +func (h *WorkflowHandlers) registerWS(dispatcher *ws.Dispatcher) { + dispatcher.RegisterFunc(ws.ActionWorkflowList, h.wsListWorkflows) + dispatcher.RegisterFunc(ws.ActionWorkflowCreate, h.wsCreateWorkflow) + dispatcher.RegisterFunc(ws.ActionWorkflowGet, h.wsGetWorkflow) + dispatcher.RegisterFunc(ws.ActionWorkflowUpdate, h.wsUpdateWorkflow) + dispatcher.RegisterFunc(ws.ActionWorkflowDelete, h.wsDeleteWorkflow) } // HTTP handlers -func (h *BoardHandlers) httpListBoards(c *gin.Context) { - resp, err := h.controller.ListBoards(c.Request.Context(), dto.ListBoardsRequest{ +func (h *WorkflowHandlers) httpListWorkflows(c *gin.Context) { + resp, err := h.controller.ListWorkflows(c.Request.Context(), dto.ListWorkflowsRequest{ WorkspaceID: c.Query("workspace_id"), }) if err != nil { - h.logger.Error("failed to list boards", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list boards"}) + h.logger.Error("failed to list workflows", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list workflows"}) return } c.JSON(http.StatusOK, resp) } -func (h *BoardHandlers) httpListBoardsByWorkspace(c *gin.Context) { - resp, err := h.controller.ListBoards(c.Request.Context(), dto.ListBoardsRequest{ +func (h *WorkflowHandlers) httpListWorkflowsByWorkspace(c *gin.Context) { + resp, err := h.controller.ListWorkflows(c.Request.Context(), dto.ListWorkflowsRequest{ WorkspaceID: c.Param("id"), }) if err != nil { - h.logger.Error("failed to list boards", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list boards"}) + h.logger.Error("failed to list workflows", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list workflows"}) return } c.JSON(http.StatusOK, resp) } -func (h *BoardHandlers) httpGetBoard(c *gin.Context) { - resp, err := h.controller.GetBoard(c.Request.Context(), dto.GetBoardRequest{ID: c.Param("id")}) +func (h *WorkflowHandlers) httpGetWorkflow(c *gin.Context) { + resp, err := h.controller.GetWorkflow(c.Request.Context(), dto.GetWorkflowRequest{ID: c.Param("id")}) if err != nil { - handleNotFound(c, h.logger, err, "board not found") + handleNotFound(c, h.logger, err, "workflow not found") return } c.JSON(http.StatusOK, resp) } -type httpCreateBoardRequest struct { +type httpCreateWorkflowRequest struct { WorkspaceID string `json:"workspace_id"` Name string `json:"name"` Description string `json:"description,omitempty"` WorkflowTemplateID *string `json:"workflow_template_id,omitempty"` } -func (h *BoardHandlers) httpCreateBoard(c *gin.Context) { - var body httpCreateBoardRequest +func (h *WorkflowHandlers) httpCreateWorkflow(c *gin.Context) { + var body httpCreateWorkflowRequest if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return @@ -105,74 +103,74 @@ func (h *BoardHandlers) httpCreateBoard(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id and name are required"}) return } - resp, err := h.controller.CreateBoard(c.Request.Context(), dto.CreateBoardRequest{ + resp, err := h.controller.CreateWorkflow(c.Request.Context(), dto.CreateWorkflowRequest{ WorkspaceID: body.WorkspaceID, Name: body.Name, Description: body.Description, WorkflowTemplateID: body.WorkflowTemplateID, }) if err != nil { - h.logger.Error("failed to create board", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create board"}) + h.logger.Error("failed to create workflow", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workflow"}) return } c.JSON(http.StatusCreated, resp) } -type httpUpdateBoardRequest struct { +type httpUpdateWorkflowRequest struct { Name *string `json:"name"` Description *string `json:"description"` } -func (h *BoardHandlers) httpUpdateBoard(c *gin.Context) { - var body httpUpdateBoardRequest +func (h *WorkflowHandlers) httpUpdateWorkflow(c *gin.Context) { + var body httpUpdateWorkflowRequest if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } - resp, err := h.controller.UpdateBoard(c.Request.Context(), dto.UpdateBoardRequest{ + resp, err := h.controller.UpdateWorkflow(c.Request.Context(), dto.UpdateWorkflowRequest{ ID: c.Param("id"), Name: body.Name, Description: body.Description, }) if err != nil { - handleNotFound(c, h.logger, err, "board not found") + handleNotFound(c, h.logger, err, "workflow not found") return } c.JSON(http.StatusOK, resp) } -func (h *BoardHandlers) httpDeleteBoard(c *gin.Context) { - resp, err := h.controller.DeleteBoard(c.Request.Context(), dto.DeleteBoardRequest{ID: c.Param("id")}) +func (h *WorkflowHandlers) httpDeleteWorkflow(c *gin.Context) { + resp, err := h.controller.DeleteWorkflow(c.Request.Context(), dto.DeleteWorkflowRequest{ID: c.Param("id")}) if err != nil { - handleNotFound(c, h.logger, err, "board not found") + handleNotFound(c, h.logger, err, "workflow not found") return } c.JSON(http.StatusOK, resp) } -func (h *BoardHandlers) httpGetBoardSnapshot(c *gin.Context) { - resp, err := h.controller.GetSnapshot(c.Request.Context(), dto.GetBoardSnapshotRequest{BoardID: c.Param("id")}) +func (h *WorkflowHandlers) httpGetWorkflowSnapshot(c *gin.Context) { + resp, err := h.controller.GetSnapshot(c.Request.Context(), dto.GetWorkflowSnapshotRequest{WorkflowID: c.Param("id")}) if err != nil { - handleNotFound(c, h.logger, err, "board not found") + handleNotFound(c, h.logger, err, "workflow not found") return } c.JSON(http.StatusOK, resp) } -func (h *BoardHandlers) httpGetWorkspaceBoardSnapshot(c *gin.Context) { +func (h *WorkflowHandlers) httpGetWorkspaceSnapshot(c *gin.Context) { workspaceID := c.Param("id") if workspaceID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "workspace id is required"}) return } - boardID := c.Query("board_id") - resp, err := h.controller.GetWorkspaceSnapshot(c.Request.Context(), dto.GetWorkspaceBoardSnapshotRequest{ + workflowID := c.Query("workflow_id") + resp, err := h.controller.GetWorkspaceSnapshot(c.Request.Context(), dto.GetWorkspaceSnapshotRequest{ WorkspaceID: workspaceID, - BoardID: boardID, + WorkflowID: workflowID, }) if err != nil { - handleNotFound(c, h.logger, err, "board not found") + handleNotFound(c, h.logger, err, "workflow not found") return } c.JSON(http.StatusOK, resp) @@ -180,7 +178,7 @@ func (h *BoardHandlers) httpGetWorkspaceBoardSnapshot(c *gin.Context) { // WS handlers -func (h *BoardHandlers) wsListBoards(ctx context.Context, msg *ws.Message) (*ws.Message, error) { +func (h *WorkflowHandlers) wsListWorkflows(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { WorkspaceID string `json:"workspace_id,omitempty"` } @@ -189,23 +187,23 @@ func (h *BoardHandlers) wsListBoards(ctx context.Context, msg *ws.Message) (*ws. return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } } - resp, err := h.controller.ListBoards(ctx, dto.ListBoardsRequest{WorkspaceID: req.WorkspaceID}) + resp, err := h.controller.ListWorkflows(ctx, dto.ListWorkflowsRequest{WorkspaceID: req.WorkspaceID}) if err != nil { - h.logger.Error("failed to list boards", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list boards", nil) + h.logger.Error("failed to list workflows", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list workflows", nil) } return ws.NewResponse(msg.ID, msg.Action, resp) } -type wsCreateBoardRequest struct { +type wsCreateWorkflowRequest struct { WorkspaceID string `json:"workspace_id"` Name string `json:"name"` Description string `json:"description,omitempty"` WorkflowTemplateID *string `json:"workflow_template_id,omitempty"` } -func (h *BoardHandlers) wsCreateBoard(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsCreateBoardRequest +func (h *WorkflowHandlers) wsCreateWorkflow(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsCreateWorkflowRequest if err := msg.ParsePayload(&req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } @@ -216,25 +214,25 @@ func (h *BoardHandlers) wsCreateBoard(ctx context.Context, msg *ws.Message) (*ws return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_id is required", nil) } - resp, err := h.controller.CreateBoard(ctx, dto.CreateBoardRequest{ + resp, err := h.controller.CreateWorkflow(ctx, dto.CreateWorkflowRequest{ WorkspaceID: req.WorkspaceID, Name: req.Name, Description: req.Description, WorkflowTemplateID: req.WorkflowTemplateID, }) if err != nil { - h.logger.Error("failed to create board", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create board", nil) + h.logger.Error("failed to create workflow", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create workflow", nil) } return ws.NewResponse(msg.ID, msg.Action, resp) } -type wsGetBoardRequest struct { +type wsGetWorkflowRequest struct { ID string `json:"id"` } -func (h *BoardHandlers) wsGetBoard(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsGetBoardRequest +func (h *WorkflowHandlers) wsGetWorkflow(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsGetWorkflowRequest if err := msg.ParsePayload(&req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } @@ -242,21 +240,21 @@ func (h *BoardHandlers) wsGetBoard(ctx context.Context, msg *ws.Message) (*ws.Me return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) } - resp, err := h.controller.GetBoard(ctx, dto.GetBoardRequest{ID: req.ID}) + resp, err := h.controller.GetWorkflow(ctx, dto.GetWorkflowRequest{ID: req.ID}) if err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Board not found", nil) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Workflow not found", nil) } return ws.NewResponse(msg.ID, msg.Action, resp) } -type wsUpdateBoardRequest struct { +type wsUpdateWorkflowRequest struct { ID string `json:"id"` Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` } -func (h *BoardHandlers) wsUpdateBoard(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsUpdateBoardRequest +func (h *WorkflowHandlers) wsUpdateWorkflow(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsUpdateWorkflowRequest if err := msg.ParsePayload(&req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } @@ -264,24 +262,24 @@ func (h *BoardHandlers) wsUpdateBoard(ctx context.Context, msg *ws.Message) (*ws return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) } - resp, err := h.controller.UpdateBoard(ctx, dto.UpdateBoardRequest{ + resp, err := h.controller.UpdateWorkflow(ctx, dto.UpdateWorkflowRequest{ ID: req.ID, Name: req.Name, Description: req.Description, }) if err != nil { - h.logger.Error("failed to update board", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update board", nil) + h.logger.Error("failed to update workflow", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update workflow", nil) } return ws.NewResponse(msg.ID, msg.Action, resp) } -type wsDeleteBoardRequest struct { +type wsDeleteWorkflowRequest struct { ID string `json:"id"` } -func (h *BoardHandlers) wsDeleteBoard(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsDeleteBoardRequest +func (h *WorkflowHandlers) wsDeleteWorkflow(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsDeleteWorkflowRequest if err := msg.ParsePayload(&req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } @@ -289,10 +287,10 @@ func (h *BoardHandlers) wsDeleteBoard(ctx context.Context, msg *ws.Message) (*ws return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) } - resp, err := h.controller.DeleteBoard(ctx, dto.DeleteBoardRequest{ID: req.ID}) + resp, err := h.controller.DeleteWorkflow(ctx, dto.DeleteWorkflowRequest{ID: req.ID}) if err != nil { - h.logger.Error("failed to delete board", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete board", nil) + h.logger.Error("failed to delete workflow", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete workflow", nil) } return ws.NewResponse(msg.ID, msg.Action, resp) } diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 82c566fd27..39e69f3f28 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -19,7 +19,7 @@ type ListMessagesOptions struct { type Task struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` + WorkflowID string `json:"workflow_id"` WorkflowStepID string `json:"workflow_step_id"` Title string `json:"title"` Description string `json:"description"` @@ -32,13 +32,13 @@ type Task struct { UpdatedAt time.Time `json:"updated_at"` } -// Board represents a Kanban board -type Board struct { +// Workflow represents a task workflow +type Workflow struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` Name string `json:"name"` Description string `json:"description"` - WorkflowTemplateID *string `json:"workflow_template_id,omitempty"` // Optional workflow template + WorkflowTemplateID *string `json:"workflow_template_id,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -444,7 +444,7 @@ func (t *Task) ToAPI() *v1.Task { return &v1.Task{ ID: t.ID, WorkspaceID: t.WorkspaceID, - BoardID: t.BoardID, + WorkflowID: t.WorkflowID, Title: t.Title, Description: t.Description, State: t.State, diff --git a/apps/backend/internal/task/models/models_test.go b/apps/backend/internal/task/models/models_test.go index 1481669140..66c24ba464 100644 --- a/apps/backend/internal/task/models/models_test.go +++ b/apps/backend/internal/task/models/models_test.go @@ -51,7 +51,7 @@ func TestTaskStructInitialization(t *testing.T) { task := Task{ ID: "task-123", WorkspaceID: "workspace-001", - BoardID: "board-456", + WorkflowID: "workflow-456", WorkflowStepID: "workflow-step-789", Title: "Test Task", Description: "A test task description", @@ -70,8 +70,8 @@ func TestTaskStructInitialization(t *testing.T) { if task.WorkspaceID != "workspace-001" { t.Errorf("expected WorkspaceID workspace-001, got %s", task.WorkspaceID) } - if task.BoardID != "board-456" { - t.Errorf("expected BoardID board-456, got %s", task.BoardID) + if task.WorkflowID != "workflow-456" { + t.Errorf("expected WorkflowID workflow-456, got %s", task.WorkflowID) } if task.WorkflowStepID != "workflow-step-789" { t.Errorf("expected WorkflowStepID workflow-step-789, got %s", task.WorkflowStepID) @@ -105,28 +105,28 @@ func TestTaskStructInitialization(t *testing.T) { } } -func TestBoardStructInitialization(t *testing.T) { +func TestWorkflowStructInitialization(t *testing.T) { now := time.Now().UTC() - board := Board{ - ID: "board-123", + wf := Workflow{ + ID: "workflow-123", WorkspaceID: "workspace-001", - Name: "Test Board", - Description: "A test board", + Name: "Test Workflow", + Description: "A test workflow", CreatedAt: now, UpdatedAt: now, } - if board.ID != "board-123" { - t.Errorf("expected ID board-123, got %s", board.ID) + if wf.ID != "workflow-123" { + t.Errorf("expected ID workflow-123, got %s", wf.ID) } - if board.WorkspaceID != "workspace-001" { - t.Errorf("expected WorkspaceID workspace-001, got %s", board.WorkspaceID) + if wf.WorkspaceID != "workspace-001" { + t.Errorf("expected WorkspaceID workspace-001, got %s", wf.WorkspaceID) } - if board.Name != "Test Board" { - t.Errorf("expected Name 'Test Board', got %s", board.Name) + if wf.Name != "Test Workflow" { + t.Errorf("expected Name 'Test Workflow', got %s", wf.Name) } - if board.Description != "A test board" { - t.Errorf("expected Description 'A test board', got %s", board.Description) + if wf.Description != "A test workflow" { + t.Errorf("expected Description 'A test workflow', got %s", wf.Description) } } @@ -135,7 +135,7 @@ func TestTaskToAPI(t *testing.T) { task := &Task{ ID: "task-123", WorkspaceID: "workspace-001", - BoardID: "board-456", + WorkflowID: "workflow-456", WorkflowStepID: "step-789", Title: "Test Task", Description: "A test task description", @@ -167,8 +167,8 @@ func TestTaskToAPI(t *testing.T) { if apiTask.WorkspaceID != task.WorkspaceID { t.Errorf("expected WorkspaceID %s, got %s", task.WorkspaceID, apiTask.WorkspaceID) } - if apiTask.BoardID != task.BoardID { - t.Errorf("expected BoardID %s, got %s", task.BoardID, apiTask.BoardID) + if apiTask.WorkflowID != task.WorkflowID { + t.Errorf("expected WorkflowID %s, got %s", task.WorkflowID, apiTask.WorkflowID) } if apiTask.Title != task.Title { t.Errorf("expected Title %s, got %s", task.Title, apiTask.Title) @@ -201,7 +201,7 @@ func TestTaskToAPIWithEmptyOptionalFields(t *testing.T) { task := &Task{ ID: "task-123", WorkspaceID: "workspace-001", - BoardID: "board-456", + WorkflowID: "workflow-456", WorkflowStepID: "step-789", Title: "Test Task", Description: "A test task description", diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index a40792050b..223a188071 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -21,12 +21,14 @@ type Repository interface { GetTask(ctx context.Context, id string) (*models.Task, error) UpdateTask(ctx context.Context, task *models.Task) error DeleteTask(ctx context.Context, id string) error - ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) + ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int) ([]*models.Task, int, error) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error - AddTaskToBoard(ctx context.Context, taskID, boardID, workflowStepID string, position int) error - RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error + CountTasksByWorkflow(ctx context.Context, workflowID string) (int, error) + CountTasksByWorkflowStep(ctx context.Context, stepID string) (int, error) + AddTaskToWorkflow(ctx context.Context, taskID, workflowID, workflowStepID string, position int) error + RemoveTaskFromWorkflow(ctx context.Context, taskID, workflowID string) error // TaskRepository operations CreateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error @@ -37,12 +39,12 @@ type Repository interface { DeleteTaskRepositoriesByTask(ctx context.Context, taskID string) error GetPrimaryTaskRepository(ctx context.Context, taskID string) (*models.TaskRepository, error) - // Board operations - CreateBoard(ctx context.Context, board *models.Board) error - GetBoard(ctx context.Context, id string) (*models.Board, error) - UpdateBoard(ctx context.Context, board *models.Board) error - DeleteBoard(ctx context.Context, id string) error - ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) + // Workflow operations + CreateWorkflow(ctx context.Context, workflow *models.Workflow) error + GetWorkflow(ctx context.Context, id string) (*models.Workflow, error) + UpdateWorkflow(ctx context.Context, workflow *models.Workflow) error + DeleteWorkflow(ctx context.Context, id string) error + ListWorkflows(ctx context.Context, workspaceID string) ([]*models.Workflow, error) // Message operations CreateMessage(ctx context.Context, message *models.Message) error diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index f129ac7870..e3b963485c 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -48,7 +48,7 @@ func (r *Repository) ensureWorkspaceIndexes() error { if _, err := r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id)`); err != nil { return err } - if _, err := r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_boards_workspace_id ON boards(workspace_id)`); err != nil { + if _, err := r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_workflows_workspace_id ON workflows(workspace_id)`); err != nil { return err } return nil @@ -119,7 +119,7 @@ func (r *Repository) initSchema() error { deleted_at DATETIME ); - CREATE TABLE IF NOT EXISTS boards ( + CREATE TABLE IF NOT EXISTS workflows ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL DEFAULT '', workflow_template_id TEXT DEFAULT '', @@ -132,7 +132,7 @@ func (r *Repository) initSchema() error { CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL DEFAULT '', - board_id TEXT NOT NULL, + workflow_id TEXT NOT NULL, workflow_step_id TEXT NOT NULL DEFAULT '', title TEXT NOT NULL, description TEXT DEFAULT '', @@ -142,7 +142,7 @@ func (r *Repository) initSchema() error { metadata TEXT DEFAULT '{}', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, - FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE + FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS task_repositories ( @@ -192,7 +192,7 @@ func (r *Repository) initSchema() error { FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE ); - CREATE INDEX IF NOT EXISTS idx_tasks_board_id ON tasks(board_id); + CREATE INDEX IF NOT EXISTS idx_tasks_workflow_id ON tasks(workflow_id); CREATE INDEX IF NOT EXISTS idx_tasks_workflow_step_id ON tasks(workflow_step_id); CREATE INDEX IF NOT EXISTS idx_task_repositories_task_id ON task_repositories(task_id); CREATE INDEX IF NOT EXISTS idx_task_repositories_repository_id ON task_repositories(repository_id); diff --git a/apps/backend/internal/task/repository/sqlite/board.go b/apps/backend/internal/task/repository/sqlite/board.go deleted file mode 100644 index cea56ed083..0000000000 --- a/apps/backend/internal/task/repository/sqlite/board.go +++ /dev/null @@ -1,136 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "fmt" - "time" - - "github.com/google/uuid" - - "github.com/kandev/kandev/internal/task/models" -) - -// AddTaskToBoard adds a task to a board with placement -func (r *Repository) AddTaskToBoard(ctx context.Context, taskID, boardID, workflowStepID string, position int) error { - _, err := r.db.ExecContext(ctx, ` - UPDATE tasks SET board_id = ?, workflow_step_id = ?, position = ?, updated_at = ? WHERE id = ? - `, boardID, workflowStepID, position, time.Now().UTC(), taskID) - return err -} - -// RemoveTaskFromBoard removes a task from a board -func (r *Repository) RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error { - _, err := r.db.ExecContext(ctx, ` - UPDATE tasks SET board_id = '', workflow_step_id = '', position = 0, updated_at = ? WHERE id = ? AND board_id = ? - `, time.Now().UTC(), taskID, boardID) - return err -} - -// Board operations - -// CreateBoard creates a new board -func (r *Repository) CreateBoard(ctx context.Context, board *models.Board) error { - if board.ID == "" { - board.ID = uuid.New().String() - } - now := time.Now().UTC() - board.CreatedAt = now - board.UpdatedAt = now - - _, err := r.db.ExecContext(ctx, ` - INSERT INTO boards (id, workspace_id, name, description, workflow_template_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, board.ID, board.WorkspaceID, board.Name, board.Description, board.WorkflowTemplateID, board.CreatedAt, board.UpdatedAt) - - return err -} - -// GetBoard retrieves a board by ID -func (r *Repository) GetBoard(ctx context.Context, id string) (*models.Board, error) { - board := &models.Board{} - var workflowTemplateID sql.NullString - - err := r.db.QueryRowContext(ctx, ` - SELECT id, workspace_id, name, description, workflow_template_id, created_at, updated_at - FROM boards WHERE id = ? - `, id).Scan(&board.ID, &board.WorkspaceID, &board.Name, &board.Description, &workflowTemplateID, &board.CreatedAt, &board.UpdatedAt) - - if err == sql.ErrNoRows { - return nil, fmt.Errorf("board not found: %s", id) - } - if err != nil { - return nil, err - } - - if workflowTemplateID.Valid { - board.WorkflowTemplateID = &workflowTemplateID.String - } - return board, nil -} - -// UpdateBoard updates an existing board -func (r *Repository) UpdateBoard(ctx context.Context, board *models.Board) error { - board.UpdatedAt = time.Now().UTC() - - result, err := r.db.ExecContext(ctx, ` - UPDATE boards SET name = ?, description = ?, workflow_template_id = ?, updated_at = ? WHERE id = ? - `, board.Name, board.Description, board.WorkflowTemplateID, board.UpdatedAt, board.ID) - if err != nil { - return err - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("board not found: %s", board.ID) - } - return nil -} - -// DeleteBoard deletes a board by ID -func (r *Repository) DeleteBoard(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM boards WHERE id = ?`, id) - if err != nil { - return err - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("board not found: %s", id) - } - return nil -} - -// ListBoards returns all boards -func (r *Repository) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) { - query := ` - SELECT id, workspace_id, name, description, workflow_template_id, created_at, updated_at FROM boards - ` - var args []interface{} - if workspaceID != "" { - query += " WHERE workspace_id = ?" - args = append(args, workspaceID) - } - query += " ORDER BY created_at DESC" - - rows, err := r.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - var result []*models.Board - for rows.Next() { - board := &models.Board{} - var workflowTemplateID sql.NullString - err := rows.Scan(&board.ID, &board.WorkspaceID, &board.Name, &board.Description, &workflowTemplateID, &board.CreatedAt, &board.UpdatedAt) - if err != nil { - return nil, err - } - if workflowTemplateID.Valid { - board.WorkflowTemplateID = &workflowTemplateID.String - } - result = append(result, board) - } - return result, rows.Err() -} diff --git a/apps/backend/internal/task/repository/sqlite/defaults.go b/apps/backend/internal/task/repository/sqlite/defaults.go index f8addbc16a..6529c01a2a 100644 --- a/apps/backend/internal/task/repository/sqlite/defaults.go +++ b/apps/backend/internal/task/repository/sqlite/defaults.go @@ -23,8 +23,8 @@ func (r *Repository) ensureDefaultWorkspace() error { } if count == 0 { - var boardCount int - if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM boards").Scan(&boardCount); err != nil { + var workflowCount int + if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM workflows").Scan(&workflowCount); err != nil { return err } var taskCount int @@ -35,7 +35,7 @@ func (r *Repository) ensureDefaultWorkspace() error { now := time.Now().UTC() workspaceName := "Default Workspace" workspaceDescription := "Default workspace" - if boardCount > 0 || taskCount > 0 { + if workflowCount > 0 || taskCount > 0 { workspaceName = "Migrated Workspace" workspaceDescription = "" } @@ -56,12 +56,12 @@ func (r *Repository) ensureDefaultWorkspace() error { return err } - if boardCount == 0 && taskCount == 0 { - boardID := uuid.New().String() + if workflowCount == 0 && taskCount == 0 { + workflowID := uuid.New().String() if _, err := r.db.ExecContext(ctx, ` - INSERT INTO boards (id, workspace_id, name, description, workflow_template_id, created_at, updated_at) + INSERT INTO workflows (id, workspace_id, name, description, workflow_template_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) - `, boardID, defaultID, "Development", "Default development board", "simple", now, now); err != nil { + `, workflowID, defaultID, "Development", "Default development workflow", "simple", now, now); err != nil { return err } // Note: Workflow steps will be created by the workflow repository after it initializes @@ -74,7 +74,7 @@ func (r *Repository) ensureDefaultWorkspace() error { } if _, err := r.db.ExecContext(ctx, ` - UPDATE boards SET workspace_id = ? WHERE workspace_id = '' OR workspace_id IS NULL + UPDATE workflows SET workspace_id = ? WHERE workspace_id = '' OR workspace_id IS NULL `, defaultWorkspaceID); err != nil { return err } @@ -82,7 +82,7 @@ func (r *Repository) ensureDefaultWorkspace() error { if _, err := r.db.ExecContext(ctx, ` UPDATE tasks SET workspace_id = ( - SELECT workspace_id FROM boards WHERE boards.id = tasks.board_id + SELECT workspace_id FROM workflows WHERE workflows.id = tasks.workflow_id ) WHERE workspace_id = '' OR workspace_id IS NULL `); err != nil { diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index 6f0a8d911e..5a52168497 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -33,9 +33,9 @@ func (r *Repository) CreateTask(ctx context.Context, task *models.Task) error { } _, err = tx.ExecContext(ctx, ` - INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at) + INSERT INTO tasks (id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, task.ID, task.WorkspaceID, task.BoardID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt) + `, task.ID, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { return fmt.Errorf("failed to rollback task insert: %w", rollbackErr) @@ -51,9 +51,9 @@ func (r *Repository) GetTask(ctx context.Context, id string) (*models.Task, erro task := &models.Task{} var metadata string err := r.db.QueryRowContext(ctx, ` - SELECT id, workspace_id, board_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at + SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at FROM tasks WHERE id = ? - `, id).Scan(&task.ID, &task.WorkspaceID, &task.BoardID, &task.WorkflowStepID, &task.Title, &task.Description, &task.State, &task.Priority, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt) + `, id).Scan(&task.ID, &task.WorkspaceID, &task.WorkflowID, &task.WorkflowStepID, &task.Title, &task.Description, &task.State, &task.Priority, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("task not found: %s", id) @@ -76,9 +76,9 @@ func (r *Repository) UpdateTask(ctx context.Context, task *models.Task) error { } result, err := r.db.ExecContext(ctx, ` - UPDATE tasks SET workspace_id = ?, board_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, metadata = ?, updated_at = ? + UPDATE tasks SET workspace_id = ?, workflow_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, metadata = ?, updated_at = ? WHERE id = ? - `, task.WorkspaceID, task.BoardID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.UpdatedAt, task.ID) + `, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.UpdatedAt, task.ID) if err != nil { return err } @@ -104,14 +104,14 @@ func (r *Repository) DeleteTask(ctx context.Context, id string) error { return nil } -// ListTasks returns all tasks for a board -func (r *Repository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) { +// ListTasks returns all tasks for a workflow +func (r *Repository) ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, workspace_id, board_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at + SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at FROM tasks - WHERE board_id = ? + WHERE workflow_id = ? ORDER BY position - `, boardID) + `, workflowID) if err != nil { return nil, err } @@ -120,10 +120,30 @@ func (r *Repository) ListTasks(ctx context.Context, boardID string) ([]*models.T return r.scanTasks(rows) } +// CountTasksByWorkflow returns the number of tasks in a workflow +func (r *Repository) CountTasksByWorkflow(ctx context.Context, workflowID string) (int, error) { + var count int + err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks WHERE workflow_id = ?`, workflowID).Scan(&count) + if err != nil { + return 0, err + } + return count, nil +} + +// CountTasksByWorkflowStep returns the number of tasks in a workflow step +func (r *Repository) CountTasksByWorkflowStep(ctx context.Context, stepID string) (int, error) { + var count int + err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks WHERE workflow_step_id = ?`, stepID).Scan(&count) + if err != nil { + return 0, err + } + return count, nil +} + // ListTasksByWorkflowStep returns all tasks in a workflow step func (r *Repository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, workspace_id, board_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at + SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at FROM tasks WHERE workflow_step_id = ? ORDER BY position `, workflowStepID) @@ -156,7 +176,7 @@ func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID strin } rows, err = r.db.QueryContext(ctx, ` - SELECT id, workspace_id, board_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at + SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at FROM tasks WHERE workspace_id = ? ORDER BY updated_at DESC @@ -183,7 +203,7 @@ func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID strin } rows, err = r.db.QueryContext(ctx, ` - SELECT DISTINCT t.id, t.workspace_id, t.board_id, t.workflow_step_id, t.title, t.description, t.state, t.priority, t.position, t.metadata, t.created_at, t.updated_at + SELECT DISTINCT t.id, t.workspace_id, t.workflow_id, t.workflow_step_id, t.title, t.description, t.state, t.priority, t.position, t.metadata, t.created_at, t.updated_at FROM tasks t LEFT JOIN task_repositories tr ON t.id = tr.task_id LEFT JOIN repositories r ON tr.repository_id = r.id @@ -221,7 +241,7 @@ func (r *Repository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { err := rows.Scan( &task.ID, &task.WorkspaceID, - &task.BoardID, + &task.WorkflowID, &task.WorkflowStepID, &task.Title, &task.Description, diff --git a/apps/backend/internal/task/repository/sqlite/workflow.go b/apps/backend/internal/task/repository/sqlite/workflow.go new file mode 100644 index 0000000000..cbec86a05c --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/workflow.go @@ -0,0 +1,136 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/kandev/kandev/internal/task/models" +) + +// AddTaskToWorkflow adds a task to a workflow with placement +func (r *Repository) AddTaskToWorkflow(ctx context.Context, taskID, workflowID, workflowStepID string, position int) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE tasks SET workflow_id = ?, workflow_step_id = ?, position = ?, updated_at = ? WHERE id = ? + `, workflowID, workflowStepID, position, time.Now().UTC(), taskID) + return err +} + +// RemoveTaskFromWorkflow removes a task from a workflow +func (r *Repository) RemoveTaskFromWorkflow(ctx context.Context, taskID, workflowID string) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE tasks SET workflow_id = '', workflow_step_id = '', position = 0, updated_at = ? WHERE id = ? AND workflow_id = ? + `, time.Now().UTC(), taskID, workflowID) + return err +} + +// Workflow operations + +// CreateWorkflow creates a new workflow +func (r *Repository) CreateWorkflow(ctx context.Context, workflow *models.Workflow) error { + if workflow.ID == "" { + workflow.ID = uuid.New().String() + } + now := time.Now().UTC() + workflow.CreatedAt = now + workflow.UpdatedAt = now + + _, err := r.db.ExecContext(ctx, ` + INSERT INTO workflows (id, workspace_id, name, description, workflow_template_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, workflow.ID, workflow.WorkspaceID, workflow.Name, workflow.Description, workflow.WorkflowTemplateID, workflow.CreatedAt, workflow.UpdatedAt) + + return err +} + +// GetWorkflow retrieves a workflow by ID +func (r *Repository) GetWorkflow(ctx context.Context, id string) (*models.Workflow, error) { + workflow := &models.Workflow{} + var workflowTemplateID sql.NullString + + err := r.db.QueryRowContext(ctx, ` + SELECT id, workspace_id, name, description, workflow_template_id, created_at, updated_at + FROM workflows WHERE id = ? + `, id).Scan(&workflow.ID, &workflow.WorkspaceID, &workflow.Name, &workflow.Description, &workflowTemplateID, &workflow.CreatedAt, &workflow.UpdatedAt) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("workflow not found: %s", id) + } + if err != nil { + return nil, err + } + + if workflowTemplateID.Valid { + workflow.WorkflowTemplateID = &workflowTemplateID.String + } + return workflow, nil +} + +// UpdateWorkflow updates an existing workflow +func (r *Repository) UpdateWorkflow(ctx context.Context, workflow *models.Workflow) error { + workflow.UpdatedAt = time.Now().UTC() + + result, err := r.db.ExecContext(ctx, ` + UPDATE workflows SET name = ?, description = ?, workflow_template_id = ?, updated_at = ? WHERE id = ? + `, workflow.Name, workflow.Description, workflow.WorkflowTemplateID, workflow.UpdatedAt, workflow.ID) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("workflow not found: %s", workflow.ID) + } + return nil +} + +// DeleteWorkflow deletes a workflow by ID +func (r *Repository) DeleteWorkflow(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM workflows WHERE id = ?`, id) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("workflow not found: %s", id) + } + return nil +} + +// ListWorkflows returns all workflows +func (r *Repository) ListWorkflows(ctx context.Context, workspaceID string) ([]*models.Workflow, error) { + query := ` + SELECT id, workspace_id, name, description, workflow_template_id, created_at, updated_at FROM workflows + ` + var args []interface{} + if workspaceID != "" { + query += " WHERE workspace_id = ?" + args = append(args, workspaceID) + } + query += " ORDER BY created_at DESC" + + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var result []*models.Workflow + for rows.Next() { + workflow := &models.Workflow{} + var workflowTemplateID sql.NullString + err := rows.Scan(&workflow.ID, &workflow.WorkspaceID, &workflow.Name, &workflow.Description, &workflowTemplateID, &workflow.CreatedAt, &workflow.UpdatedAt) + if err != nil { + return nil, err + } + if workflowTemplateID.Valid { + workflow.WorkflowTemplateID = &workflowTemplateID.String + } + result = append(result, workflow) + } + return result, rows.Err() +} diff --git a/apps/backend/internal/task/repository/sqlite_test.go b/apps/backend/internal/task/repository/sqlite_test.go index bc62d8fc39..0eb06ad0ba 100644 --- a/apps/backend/internal/task/repository/sqlite_test.go +++ b/apps/backend/internal/task/repository/sqlite_test.go @@ -78,22 +78,22 @@ func TestSQLiteRepository_SeedsDefaultWorkspace(t *testing.T) { t.Errorf("expected Default Workspace, got %s", workspaces[0].Name) } - boards, err := repo.ListBoards(ctx, workspaces[0].ID) + workflows, err := repo.ListWorkflows(ctx, workspaces[0].ID) if err != nil { - t.Fatalf("failed to list boards: %v", err) + t.Fatalf("failed to list workflows: %v", err) } - if len(boards) != 1 { - t.Fatalf("expected 1 board, got %d", len(boards)) + if len(workflows) != 1 { + t.Fatalf("expected 1 workflow, got %d", len(workflows)) } - if boards[0].Name != "Development" { - t.Errorf("expected Development board, got %s", boards[0].Name) + if workflows[0].Name != "Development" { + t.Errorf("expected Development workflow, got %s", workflows[0].Name) } // Note: workflow steps are now managed by the workflow repository, not the task repository } -// Board CRUD tests +// Workflow CRUD tests -func TestSQLiteRepository_BoardCRUD(t *testing.T) { +func TestSQLiteRepository_WorkflowCRUD(t *testing.T) { repo, cleanup := createTestSQLiteRepo(t) defer cleanup() ctx := context.Background() @@ -104,68 +104,68 @@ func TestSQLiteRepository_BoardCRUD(t *testing.T) { } // Create - board := &models.Board{WorkspaceID: workspace.ID, Name: "Test Board", Description: "A test board"} - if err := repo.CreateBoard(ctx, board); err != nil { - t.Fatalf("failed to create board: %v", err) + workflow := &models.Workflow{WorkspaceID: workspace.ID, Name: "Test Workflow", Description: "A test workflow"} + if err := repo.CreateWorkflow(ctx, workflow); err != nil { + t.Fatalf("failed to create workflow: %v", err) } - if board.ID == "" { - t.Error("expected board ID to be set") + if workflow.ID == "" { + t.Error("expected workflow ID to be set") } - if board.CreatedAt.IsZero() { + if workflow.CreatedAt.IsZero() { t.Error("expected CreatedAt to be set") } // Get - retrieved, err := repo.GetBoard(ctx, board.ID) + retrieved, err := repo.GetWorkflow(ctx, workflow.ID) if err != nil { - t.Fatalf("failed to get board: %v", err) + t.Fatalf("failed to get workflow: %v", err) } - if retrieved.Name != "Test Board" { - t.Errorf("expected name 'Test Board', got %s", retrieved.Name) + if retrieved.Name != "Test Workflow" { + t.Errorf("expected name 'Test Workflow', got %s", retrieved.Name) } // Update - board.Name = "Updated Name" - if err := repo.UpdateBoard(ctx, board); err != nil { - t.Fatalf("failed to update board: %v", err) + workflow.Name = "Updated Name" + if err := repo.UpdateWorkflow(ctx, workflow); err != nil { + t.Fatalf("failed to update workflow: %v", err) } - retrieved, _ = repo.GetBoard(ctx, board.ID) + retrieved, _ = repo.GetWorkflow(ctx, workflow.ID) if retrieved.Name != "Updated Name" { t.Errorf("expected name 'Updated Name', got %s", retrieved.Name) } // Delete - if err := repo.DeleteBoard(ctx, board.ID); err != nil { - t.Fatalf("failed to delete board: %v", err) + if err := repo.DeleteWorkflow(ctx, workflow.ID); err != nil { + t.Fatalf("failed to delete workflow: %v", err) } - _, err = repo.GetBoard(ctx, board.ID) + _, err = repo.GetWorkflow(ctx, workflow.ID) if err == nil { - t.Error("expected board to be deleted") + t.Error("expected workflow to be deleted") } } -func TestSQLiteRepository_BoardNotFound(t *testing.T) { +func TestSQLiteRepository_WorkflowNotFound(t *testing.T) { repo, cleanup := createTestSQLiteRepo(t) defer cleanup() ctx := context.Background() - _, err := repo.GetBoard(ctx, "nonexistent") + _, err := repo.GetWorkflow(ctx, "nonexistent") if err == nil { - t.Error("expected error for nonexistent board") + t.Error("expected error for nonexistent workflow") } - err = repo.UpdateBoard(ctx, &models.Board{ID: "nonexistent", Name: "Test"}) + err = repo.UpdateWorkflow(ctx, &models.Workflow{ID: "nonexistent", Name: "Test"}) if err == nil { - t.Error("expected error for updating nonexistent board") + t.Error("expected error for updating nonexistent workflow") } - err = repo.DeleteBoard(ctx, "nonexistent") + err = repo.DeleteWorkflow(ctx, "nonexistent") if err == nil { - t.Error("expected error for deleting nonexistent board") + t.Error("expected error for deleting nonexistent workflow") } } -func TestSQLiteRepository_ListBoards(t *testing.T) { +func TestSQLiteRepository_ListWorkflows(t *testing.T) { repo, cleanup := createTestSQLiteRepo(t) defer cleanup() ctx := context.Background() @@ -173,16 +173,16 @@ func TestSQLiteRepository_ListBoards(t *testing.T) { _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-2", Name: "Workspace 2"}) - _ = repo.CreateBoard(ctx, &models.Board{ID: "board-1", WorkspaceID: "ws-1", Name: "Board 1"}) - _ = repo.CreateBoard(ctx, &models.Board{ID: "board-2", WorkspaceID: "ws-1", Name: "Board 2"}) - _ = repo.CreateBoard(ctx, &models.Board{ID: "board-3", WorkspaceID: "ws-2", Name: "Board 3"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Workflow 1"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-2", WorkspaceID: "ws-1", Name: "Workflow 2"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-3", WorkspaceID: "ws-2", Name: "Workflow 3"}) - boards, err := repo.ListBoards(ctx, "ws-1") + workflows, err := repo.ListWorkflows(ctx, "ws-1") if err != nil { - t.Fatalf("failed to list boards: %v", err) + t.Fatalf("failed to list workflows: %v", err) } - if len(boards) != 2 { - t.Errorf("expected 2 boards, got %d", len(boards)) + if len(workflows) != 2 { + t.Errorf("expected 2 workflows, got %d", len(workflows)) } } @@ -193,15 +193,15 @@ func TestSQLiteRepository_TaskCRUD(t *testing.T) { defer cleanup() ctx := context.Background() - // Create workspace and board for foreign keys + // Create workspace and workflow for foreign keys _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"} - _ = repo.CreateBoard(ctx, board) + workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) // Create task (workflow steps are managed by workflow repository) task := &models.Task{ WorkspaceID: "ws-1", - BoardID: "board-123", + WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task", Description: "A test task", @@ -274,11 +274,11 @@ func TestSQLiteRepository_UpdateTaskState(t *testing.T) { defer cleanup() ctx := context.Background() - // Create workspace, board, and task + // Create workspace, workflow, and task _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"} - _ = repo.CreateBoard(ctx, board) - task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test", State: v1.TaskStateTODO} + workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test", State: v1.TaskStateTODO} _ = repo.CreateTask(ctx, task) err := repo.UpdateTaskState(ctx, "task-123", v1.TaskStateInProgress) @@ -308,15 +308,15 @@ func TestSQLiteRepository_ListTasks(t *testing.T) { defer cleanup() ctx := context.Background() - // Create workspace and board + // Create workspace and workflow _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"} - _ = repo.CreateBoard(ctx, board) + workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Task 1"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Task 2"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Task 1"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Task 2"}) - tasks, err := repo.ListTasks(ctx, "board-123") + tasks, err := repo.ListTasks(ctx, "wf-123") if err != nil { t.Fatalf("failed to list tasks: %v", err) } @@ -330,15 +330,15 @@ func TestSQLiteRepository_ListTasksByWorkflowStep(t *testing.T) { defer cleanup() ctx := context.Background() - // Create workspace and board + // Create workspace and workflow _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"} - _ = repo.CreateBoard(ctx, board) + workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) // Tasks with different workflow steps - _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-1", Title: "Task 1"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-1", Title: "Task 2"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-2", Title: "Task 3"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Task 1"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Task 2"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-2", Title: "Task 3"}) tasks, err := repo.ListTasksByWorkflowStep(ctx, "step-1") if err != nil { @@ -354,20 +354,20 @@ func TestSQLiteRepository_ListTasksByWorkspace(t *testing.T) { defer cleanup() ctx := context.Background() - // Create workspaces and board + // Create workspaces and workflow _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-2", Name: "Workspace 2"}) - board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"} - _ = repo.CreateBoard(ctx, board) + workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) // Create tasks in workspace 1 - _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-1", Title: "Task One"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-1", Title: "Task Two"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-1", Title: "Task Three"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Task One"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Task Two"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Task Three"}) // Create task in workspace 2 - board2 := &models.Board{ID: "board-456", WorkspaceID: "ws-2", Name: "Test Board 2"} - _ = repo.CreateBoard(ctx, board2) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-4", WorkspaceID: "ws-2", BoardID: "board-456", WorkflowStepID: "step-2", Title: "Task Four"}) + workflow2 := &models.Workflow{ID: "wf-456", WorkspaceID: "ws-2", Name: "Test Workflow 2"} + _ = repo.CreateWorkflow(ctx, workflow2) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-4", WorkspaceID: "ws-2", WorkflowID: "wf-456", WorkflowStepID: "step-2", Title: "Task Four"}) // Test basic listing without search tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", 1, 10) @@ -408,17 +408,17 @@ func TestSQLiteRepository_ListTasksByWorkspaceWithSearch(t *testing.T) { defer cleanup() ctx := context.Background() - // Create workspace, board, and repository + // Create workspace, workflow, and repository _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) - board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"} - _ = repo.CreateBoard(ctx, board) + workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) repository := &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "MyProject", LocalPath: "/home/user/projects/myproject"} _ = repo.CreateRepository(ctx, repository) // Create tasks with different titles and descriptions - _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-1", Title: "Fix authentication bug", Description: "Users cannot login"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-1", Title: "Add new feature", Description: "Implement dark mode"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-1", Title: "Refactor codebase", Description: "Clean up authentication module"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Fix authentication bug", Description: "Users cannot login"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Add new feature", Description: "Implement dark mode"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-1", Title: "Refactor codebase", Description: "Clean up authentication module"}) // Link task-1 to the repository _ = repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "tr-1", TaskID: "task-1", RepositoryID: "repo-1", BaseBranch: "main"}) @@ -494,8 +494,8 @@ func TestSQLiteRepository_Persistence(t *testing.T) { } _ = repo1.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - board := &models.Board{ID: "persist-board", WorkspaceID: "ws-1", Name: "Persistent Board"} - _ = repo1.CreateBoard(ctx, board) + workflow := &models.Workflow{ID: "persist-wf", WorkspaceID: "ws-1", Name: "Persistent Workflow"} + _ = repo1.CreateWorkflow(ctx, workflow) if err := repo1.Close(); err != nil { t.Fatalf("failed to close repo: %v", err) } @@ -521,12 +521,12 @@ func TestSQLiteRepository_Persistence(t *testing.T) { } }() - retrieved, err := repo2.GetBoard(ctx, "persist-board") + retrieved, err := repo2.GetWorkflow(ctx, "persist-wf") if err != nil { - t.Fatalf("failed to get board after reopen: %v", err) + t.Fatalf("failed to get workflow after reopen: %v", err) } - if retrieved.Name != "Persistent Board" { - t.Errorf("expected name 'Persistent Board', got %s", retrieved.Name) + if retrieved.Name != "Persistent Workflow" { + t.Errorf("expected name 'Persistent Workflow', got %s", retrieved.Name) } } @@ -562,12 +562,12 @@ func TestSQLiteRepository_MessageCRUD(t *testing.T) { defer cleanup() ctx := context.Background() - // Create board first (required for foreign key constraints) - board := &models.Board{ID: "board-123", Name: "Test Board"} - _ = repo.CreateBoard(ctx, board) + // Create workflow first (required for foreign key constraints) + workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) // Create a task first - task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"} + task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} _ = repo.CreateTask(ctx, task) sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123") turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-123") @@ -635,14 +635,14 @@ func TestSQLiteRepository_ListMessages(t *testing.T) { defer cleanup() ctx := context.Background() - // Create board first (required for foreign key constraints) - board := &models.Board{ID: "board-123", Name: "Test Board"} - _ = repo.CreateBoard(ctx, board) + // Create workflow first (required for foreign key constraints) + workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) // Create tasks - task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"} + task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} _ = repo.CreateTask(ctx, task) - task2 := &models.Task{ID: "task-456", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task 2"} + task2 := &models.Task{ID: "task-456", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task 2"} _ = repo.CreateTask(ctx, task2) sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123") session2ID := setupSQLiteTestSession(ctx, repo, task2.ID, "session-456") @@ -668,9 +668,9 @@ func TestSQLiteRepository_ListMessagesPagination(t *testing.T) { defer cleanup() ctx := context.Background() - board := &models.Board{ID: "board-123", Name: "Test Board"} - _ = repo.CreateBoard(ctx, board) - task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"} + workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} _ = repo.CreateTask(ctx, task) sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123") turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-123") @@ -727,12 +727,12 @@ func TestSQLiteRepository_MessageWithRequestsInput(t *testing.T) { defer cleanup() ctx := context.Background() - // Create board first (required for foreign key constraints) - board := &models.Board{ID: "board-123", Name: "Test Board"} - _ = repo.CreateBoard(ctx, board) + // Create workflow first (required for foreign key constraints) + workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) // Create a task - task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"} + task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} _ = repo.CreateTask(ctx, task) sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123") turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-123") @@ -764,10 +764,10 @@ func TestSQLiteRepository_TaskSessionCRUD(t *testing.T) { defer cleanup() ctx := context.Background() - // Create board and task first (required for foreign key constraints) - board := &models.Board{ID: "board-123", Name: "Test Board"} - _ = repo.CreateBoard(ctx, board) - task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"} + // Create workflow and task first (required for foreign key constraints) + workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} _ = repo.CreateTask(ctx, task) // Create agent session @@ -858,10 +858,10 @@ func TestSQLiteRepository_TaskSessionByTaskID(t *testing.T) { defer cleanup() ctx := context.Background() - // Create board and task - board := &models.Board{ID: "board-123", Name: "Test Board"} - _ = repo.CreateBoard(ctx, board) - task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"} + // Create workflow and task + workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} _ = repo.CreateTask(ctx, task) // Create multiple sessions for the same task (simulating session history) @@ -923,12 +923,12 @@ func TestSQLiteRepository_ListTaskSessions(t *testing.T) { defer cleanup() ctx := context.Background() - // Create board and tasks - board := &models.Board{ID: "board-123", Name: "Test Board"} - _ = repo.CreateBoard(ctx, board) - task1 := &models.Task{ID: "task-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Task 1"} + // Create workflow and tasks + workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + task1 := &models.Task{ID: "task-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Task 1"} _ = repo.CreateTask(ctx, task1) - task2 := &models.Task{ID: "task-2", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Task 2"} + task2 := &models.Task{ID: "task-2", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Task 2"} _ = repo.CreateTask(ctx, task2) // Create sessions for different tasks @@ -967,10 +967,10 @@ func TestSQLiteRepository_UpdateTaskSessionState(t *testing.T) { defer cleanup() ctx := context.Background() - // Create board and task - board := &models.Board{ID: "board-123", Name: "Test Board"} - _ = repo.CreateBoard(ctx, board) - task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"} + // Create workflow and task + workflow := &models.Workflow{ID: "wf-123", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + task := &models.Task{ID: "task-123", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"} _ = repo.CreateTask(ctx, task) // Create an agent session @@ -1066,9 +1066,9 @@ func TestSQLiteRepository_CompleteRunningToolCallsForTurn(t *testing.T) { ctx := context.Background() // Setup - board := &models.Board{ID: "board-1", Name: "Test Board"} - _ = repo.CreateBoard(ctx, board) - task := &models.Task{ID: "task-1", BoardID: "board-1", WorkflowStepID: "step-1", Title: "Test Task"} + workflow := &models.Workflow{ID: "wf-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + task := &models.Task{ID: "task-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Test Task"} _ = repo.CreateTask(ctx, task) sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-1") turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-1") diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 4e3d0196fe..2421fad04b 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -22,6 +22,7 @@ import ( "github.com/kandev/kandev/internal/sysprompt" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" + wfmodels "github.com/kandev/kandev/internal/workflow/models" "github.com/kandev/kandev/internal/worktree" ) @@ -52,36 +53,22 @@ type TaskExecutionStopper interface { StopSession(ctx context.Context, sessionID, reason string, force bool) error } -// WorkflowStepCreator creates workflow steps from a template for a board. +// WorkflowStepCreator creates workflow steps from a template for a workflow. type WorkflowStepCreator interface { - CreateStepsFromTemplate(ctx context.Context, boardID, templateID string) error + CreateStepsFromTemplate(ctx context.Context, workflowID, templateID string) error } // WorkflowStepGetter retrieves workflow step information. type WorkflowStepGetter interface { - GetStep(ctx context.Context, stepID string) (*WorkflowStep, error) - // GetNextStepByPosition returns the next step after the given position for a board. + GetStep(ctx context.Context, stepID string) (*wfmodels.WorkflowStep, error) + // GetNextStepByPosition returns the next step after the given position for a workflow. // Returns nil if there is no next step (i.e., current step is the last one). - GetNextStepByPosition(ctx context.Context, boardID string, currentPosition int) (*WorkflowStep, error) + GetNextStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) } -// WorkflowStep represents a workflow step (mirrors workflow/models.WorkflowStep). -// Defined here to avoid circular dependency with workflow package. -type WorkflowStep struct { - ID string - BoardID string - Name string - StepType string - Position int - Color string - AutoStartAgent bool - PlanMode bool - RequireApproval bool - PromptPrefix string - PromptSuffix string - AllowManualMove bool - OnCompleteStepID *string - OnApprovalStepID *string +// StartStepResolver resolves the starting step for a workflow. +type StartStepResolver interface { + ResolveStartStep(ctx context.Context, workflowID string) (string, error) } var ( @@ -118,6 +105,7 @@ type Service struct { executionStopper TaskExecutionStopper workflowStepCreator WorkflowStepCreator workflowStepGetter WorkflowStepGetter + startStepResolver StartStepResolver } // NewService creates a new task service @@ -140,7 +128,7 @@ func (s *Service) SetExecutionStopper(stopper TaskExecutionStopper) { s.executionStopper = stopper } -// SetWorkflowStepCreator wires the workflow step creator for board creation. +// SetWorkflowStepCreator wires the workflow step creator for workflow creation. func (s *Service) SetWorkflowStepCreator(creator WorkflowStepCreator) { s.workflowStepCreator = creator } @@ -150,6 +138,11 @@ func (s *Service) SetWorkflowStepGetter(getter WorkflowStepGetter) { s.workflowStepGetter = getter } +// SetStartStepResolver wires the start step resolver for CreateTask. +func (s *Service) SetStartStepResolver(resolver StartStepResolver) { + s.startStepResolver = resolver +} + // Request types // TaskRepositoryInput for creating/updating task repositories @@ -164,7 +157,7 @@ type TaskRepositoryInput struct { // CreateTaskRequest contains the data for creating a new task type CreateTaskRequest struct { WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` + WorkflowID string `json:"workflow_id"` WorkflowStepID string `json:"workflow_step_id"` Title string `json:"title"` Description string `json:"description"` @@ -186,16 +179,16 @@ type UpdateTaskRequest struct { Metadata map[string]interface{} `json:"metadata,omitempty"` } -// CreateBoardRequest contains the data for creating a new board -type CreateBoardRequest struct { +// CreateWorkflowRequest contains the data for creating a new workflow +type CreateWorkflowRequest struct { WorkspaceID string `json:"workspace_id"` Name string `json:"name"` Description string `json:"description"` WorkflowTemplateID *string `json:"workflow_template_id,omitempty"` } -// UpdateBoardRequest contains the data for updating a board -type UpdateBoardRequest struct { +// UpdateWorkflowRequest contains the data for updating a workflow +type UpdateWorkflowRequest struct { Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` } @@ -320,6 +313,19 @@ type UpdateRepositoryScriptRequest struct { // CreateTask creates a new task and publishes a task.created event func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*models.Task, error) { + // Auto-resolve start step if not provided + workflowStepID := req.WorkflowStepID + if workflowStepID == "" && req.WorkflowID != "" && s.startStepResolver != nil { + resolvedID, err := s.startStepResolver.ResolveStartStep(ctx, req.WorkflowID) + if err != nil { + s.logger.Warn("failed to resolve start step, using empty", + zap.String("workflow_id", req.WorkflowID), + zap.Error(err)) + } else { + workflowStepID = resolvedID + } + } + state := v1.TaskStateCreated if req.State != nil { state = *req.State @@ -327,8 +333,8 @@ func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*mode task := &models.Task{ ID: uuid.New().String(), WorkspaceID: req.WorkspaceID, - BoardID: req.BoardID, - WorkflowStepID: req.WorkflowStepID, + WorkflowID: req.WorkflowID, + WorkflowStepID: workflowStepID, Title: req.Title, Description: req.Description, State: state, @@ -721,9 +727,9 @@ func (s *Service) performTaskCleanup( return errs } -// ListTasks returns all tasks for a board -func (s *Service) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) { - tasks, err := s.repo.ListTasks(ctx, boardID) +// ListTasks returns all tasks for a workflow +func (s *Service) ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error) { + tasks, err := s.repo.ListTasks(ctx, workflowID) if err != nil { return nil, err } @@ -833,13 +839,12 @@ func (s *Service) UpdateSessionReviewStatus(ctx context.Context, sessionID strin type ApproveSessionResult struct { Session *models.TaskSession Task *models.Task - WorkflowStep *WorkflowStep + WorkflowStep *wfmodels.WorkflowStep } // ApproveSession approves a session's current step and moves it to the next step. -// It checks OnApprovalStepID first, then OnCompleteStepID, then falls back to the next step by position. -// This handles both dedicated review steps (with explicit transition IDs) and simple linear workflows -// where steps have AutoStartAgent and RequireApproval enabled without explicit transition configuration. +// It reads the step's on_turn_complete actions to determine where to transition. +// If no transition actions are configured, it falls back to the next step by position. func (s *Service) ApproveSession(ctx context.Context, sessionID string) (*ApproveSessionResult, error) { // Update review status to approved if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, "approved"); err != nil { @@ -863,30 +868,43 @@ func (s *Service) ApproveSession(ctx context.Context, sessionID string) (*Approv zap.String("workflow_step_id", *session.WorkflowStepID), zap.Error(err)) } else { - // Determine the target step: - // 1. Prefer OnApprovalStepID (explicit approval transition) - // 2. Fall back to OnCompleteStepID (explicit completion transition) - // 3. Fall back to next step by position (for simple linear workflows) + // Determine the target step from on_turn_complete actions var newStepID string - if step.OnApprovalStepID != nil && *step.OnApprovalStepID != "" { - newStepID = *step.OnApprovalStepID - } else if step.OnCompleteStepID != nil && *step.OnCompleteStepID != "" { - newStepID = *step.OnCompleteStepID - } else { - // Try to find the next step by position - nextStep, err := s.workflowStepGetter.GetNextStepByPosition(ctx, step.BoardID, step.Position) + for _, action := range step.Events.OnTurnComplete { + switch action.Type { + case "move_to_next": + nextStep, err := s.workflowStepGetter.GetNextStepByPosition(ctx, step.WorkflowID, step.Position) + if err != nil { + s.logger.Warn("failed to get next step by position", + zap.String("workflow_id", step.WorkflowID), + zap.Int("current_position", step.Position), + zap.Error(err)) + } else if nextStep != nil { + newStepID = nextStep.ID + } + case "move_to_step": + if stepID, ok := action.Config["step_id"].(string); ok && stepID != "" { + newStepID = stepID + } + } + if newStepID != "" { + break + } + } + + // Fall back to next step by position if no transition actions found + if newStepID == "" && len(step.Events.OnTurnComplete) == 0 { + nextStep, err := s.workflowStepGetter.GetNextStepByPosition(ctx, step.WorkflowID, step.Position) if err != nil { - s.logger.Warn("failed to get next step by position", - zap.String("board_id", step.BoardID), + s.logger.Warn("failed to get next step by position for fallback", + zap.String("workflow_id", step.WorkflowID), zap.Int("current_position", step.Position), zap.Error(err)) } else if nextStep != nil { newStepID = nextStep.ID - s.logger.Info("using next step by position for approval transition", + s.logger.Info("using next step by position for approval transition (fallback)", zap.String("current_step", step.Name), - zap.String("next_step", nextStep.Name), - zap.Int("current_position", step.Position), - zap.Int("next_position", nextStep.Position)) + zap.String("next_step", nextStep.Name)) } } @@ -1007,35 +1025,29 @@ func (s *Service) UpdateTaskMetadata(ctx context.Context, id string, metadata ma // MoveTaskResult contains the result of a MoveTask operation. type MoveTaskResult struct { Task *models.Task - WorkflowStep *WorkflowStep + WorkflowStep *wfmodels.WorkflowStep } // MoveTask moves a task to a different workflow step and position -func (s *Service) MoveTask(ctx context.Context, id string, boardID string, workflowStepID string, position int) (*MoveTaskResult, error) { +func (s *Service) MoveTask(ctx context.Context, id string, workflowID string, workflowStepID string, position int) (*MoveTaskResult, error) { task, err := s.repo.GetTask(ctx, id) if err != nil { return nil, err } - // Check if task's primary session is in a review step with pending approval + // Check if task's primary session is in a step with pending approval // If so, prevent moving forward - user must use Approve button or send a message if task.WorkflowStepID != workflowStepID { primarySession, err := s.repo.GetPrimarySessionByTaskID(ctx, id) if err == nil && primarySession != nil { if primarySession.ReviewStatus != nil && *primarySession.ReviewStatus == "pending" { - // Check if current step requires approval - if s.workflowStepGetter != nil && primarySession.WorkflowStepID != nil { - currentStep, err := s.workflowStepGetter.GetStep(ctx, *primarySession.WorkflowStepID) - if err == nil && currentStep.RequireApproval { - return nil, fmt.Errorf("task is pending approval - use Approve button to proceed or send a message to request changes") - } - } + return nil, fmt.Errorf("task is pending approval - use Approve button to proceed or send a message to request changes") } } } oldState := task.State - task.BoardID = boardID + task.WorkflowID = workflowID task.WorkflowStepID = workflowStepID task.Position = position task.UpdatedAt = time.Now().UTC() @@ -1078,7 +1090,7 @@ func (s *Service) MoveTask(ctx context.Context, id string, boardID string, workf s.logger.Info("task moved", zap.String("task_id", id), - zap.String("board_id", boardID), + zap.String("workflow_id", workflowID), zap.String("workflow_step_id", workflowStepID), zap.Int("position", position)) @@ -1100,6 +1112,80 @@ func (s *Service) MoveTask(ctx context.Context, id string, boardID string, workf return result, nil } +// CountTasksByWorkflow returns the number of tasks in a workflow +func (s *Service) CountTasksByWorkflow(ctx context.Context, workflowID string) (int, error) { + return s.repo.CountTasksByWorkflow(ctx, workflowID) +} + +// CountTasksByWorkflowStep returns the number of tasks in a workflow step +func (s *Service) CountTasksByWorkflowStep(ctx context.Context, stepID string) (int, error) { + return s.repo.CountTasksByWorkflowStep(ctx, stepID) +} + +// BulkMoveTasksResult contains the result of a BulkMoveTasks operation. +type BulkMoveTasksResult struct { + MovedCount int +} + +// BulkMoveTasks moves all tasks from a source workflow/step to a target workflow/step. +// If sourceStepID is empty, all tasks in the source workflow are moved. +func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceStepID, targetWorkflowID, targetStepID string) (*BulkMoveTasksResult, error) { + // Get the tasks to move + var tasks []*models.Task + var err error + if sourceStepID != "" { + tasks, err = s.repo.ListTasksByWorkflowStep(ctx, sourceStepID) + } else { + tasks, err = s.repo.ListTasks(ctx, sourceWorkflowID) + } + if err != nil { + return nil, fmt.Errorf("failed to list tasks for bulk move: %w", err) + } + + if len(tasks) == 0 { + return &BulkMoveTasksResult{MovedCount: 0}, nil + } + + now := time.Now().UTC() + for i, task := range tasks { + task.WorkflowID = targetWorkflowID + task.WorkflowStepID = targetStepID + task.Position = i + task.UpdatedAt = now + + if err := s.repo.UpdateTask(ctx, task); err != nil { + s.logger.Error("failed to move task in bulk move", + zap.String("task_id", task.ID), + zap.Error(err)) + return nil, fmt.Errorf("failed to move task %s: %w", task.ID, err) + } + + // Update active session's workflow_step_id + activeSession, err := s.repo.GetActiveTaskSessionByTaskID(ctx, task.ID) + if err == nil && activeSession != nil { + if activeSession.WorkflowStepID == nil || *activeSession.WorkflowStepID != targetStepID { + if err := s.repo.UpdateSessionWorkflowStep(ctx, activeSession.ID, targetStepID); err != nil { + s.logger.Warn("failed to update session workflow step during bulk move", + zap.String("task_id", task.ID), + zap.String("session_id", activeSession.ID), + zap.Error(err)) + } + } + } + + s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) + } + + s.logger.Info("bulk moved tasks", + zap.String("source_workflow_id", sourceWorkflowID), + zap.String("source_step_id", sourceStepID), + zap.String("target_workflow_id", targetWorkflowID), + zap.String("target_step_id", targetStepID), + zap.Int("moved_count", len(tasks))) + + return &BulkMoveTasksResult{MovedCount: len(tasks)}, nil +} + // Workspace operations // CreateWorkspace creates a new workspace @@ -1194,11 +1280,11 @@ func (s *Service) ListWorkspaces(ctx context.Context) ([]*models.Workspace, erro return s.repo.ListWorkspaces(ctx) } -// Board operations +// Workflow operations -// CreateBoard creates a new board -func (s *Service) CreateBoard(ctx context.Context, req *CreateBoardRequest) (*models.Board, error) { - board := &models.Board{ +// CreateWorkflow creates a new workflow +func (s *Service) CreateWorkflow(ctx context.Context, req *CreateWorkflowRequest) (*models.Workflow, error) { + workflow := &models.Workflow{ ID: uuid.New().String(), WorkspaceID: req.WorkspaceID, Name: req.Name, @@ -1206,76 +1292,76 @@ func (s *Service) CreateBoard(ctx context.Context, req *CreateBoardRequest) (*mo WorkflowTemplateID: req.WorkflowTemplateID, } - if err := s.repo.CreateBoard(ctx, board); err != nil { - s.logger.Error("failed to create board", zap.Error(err)) + if err := s.repo.CreateWorkflow(ctx, workflow); err != nil { + s.logger.Error("failed to create workflow", zap.Error(err)) return nil, err } // Create workflow steps from template if specified if req.WorkflowTemplateID != nil && *req.WorkflowTemplateID != "" && s.workflowStepCreator != nil { - if err := s.workflowStepCreator.CreateStepsFromTemplate(ctx, board.ID, *req.WorkflowTemplateID); err != nil { + if err := s.workflowStepCreator.CreateStepsFromTemplate(ctx, workflow.ID, *req.WorkflowTemplateID); err != nil { s.logger.Error("failed to create workflow steps from template", - zap.String("board_id", board.ID), + zap.String("workflow_id", workflow.ID), zap.String("template_id", *req.WorkflowTemplateID), zap.Error(err)) - // Don't fail board creation, just log the error + // Don't fail workflow creation, just log the error } } - s.publishBoardEvent(ctx, events.BoardCreated, board) - s.logger.Info("board created", zap.String("board_id", board.ID), zap.String("name", board.Name)) - return board, nil + s.publishWorkflowEvent(ctx, events.WorkflowCreated, workflow) + s.logger.Info("workflow created", zap.String("workflow_id", workflow.ID), zap.String("name", workflow.Name)) + return workflow, nil } -// GetBoard retrieves a board by ID -func (s *Service) GetBoard(ctx context.Context, id string) (*models.Board, error) { - return s.repo.GetBoard(ctx, id) +// GetWorkflow retrieves a workflow by ID +func (s *Service) GetWorkflow(ctx context.Context, id string) (*models.Workflow, error) { + return s.repo.GetWorkflow(ctx, id) } -// UpdateBoard updates an existing board -func (s *Service) UpdateBoard(ctx context.Context, id string, req *UpdateBoardRequest) (*models.Board, error) { - board, err := s.repo.GetBoard(ctx, id) +// UpdateWorkflow updates an existing workflow +func (s *Service) UpdateWorkflow(ctx context.Context, id string, req *UpdateWorkflowRequest) (*models.Workflow, error) { + workflow, err := s.repo.GetWorkflow(ctx, id) if err != nil { return nil, err } if req.Name != nil { - board.Name = *req.Name + workflow.Name = *req.Name } if req.Description != nil { - board.Description = *req.Description + workflow.Description = *req.Description } - board.UpdatedAt = time.Now().UTC() + workflow.UpdatedAt = time.Now().UTC() - if err := s.repo.UpdateBoard(ctx, board); err != nil { - s.logger.Error("failed to update board", zap.String("board_id", id), zap.Error(err)) + if err := s.repo.UpdateWorkflow(ctx, workflow); err != nil { + s.logger.Error("failed to update workflow", zap.String("workflow_id", id), zap.Error(err)) return nil, err } - s.publishBoardEvent(ctx, events.BoardUpdated, board) - s.logger.Info("board updated", zap.String("board_id", board.ID)) - return board, nil + s.publishWorkflowEvent(ctx, events.WorkflowUpdated, workflow) + s.logger.Info("workflow updated", zap.String("workflow_id", workflow.ID)) + return workflow, nil } -// DeleteBoard deletes a board -func (s *Service) DeleteBoard(ctx context.Context, id string) error { - board, err := s.repo.GetBoard(ctx, id) +// DeleteWorkflow deletes a workflow +func (s *Service) DeleteWorkflow(ctx context.Context, id string) error { + workflow, err := s.repo.GetWorkflow(ctx, id) if err != nil { return err } - if err := s.repo.DeleteBoard(ctx, id); err != nil { - s.logger.Error("failed to delete board", zap.String("board_id", id), zap.Error(err)) + if err := s.repo.DeleteWorkflow(ctx, id); err != nil { + s.logger.Error("failed to delete workflow", zap.String("workflow_id", id), zap.Error(err)) return err } - s.publishBoardEvent(ctx, events.BoardDeleted, board) - s.logger.Info("board deleted", zap.String("board_id", id)) + s.publishWorkflowEvent(ctx, events.WorkflowDeleted, workflow) + s.logger.Info("workflow deleted", zap.String("workflow_id", id)) return nil } -// ListBoards returns all boards for a workspace (or all if empty) -func (s *Service) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) { - return s.repo.ListBoards(ctx, workspaceID) +// ListWorkflows returns all workflows for a workspace (or all if empty) +func (s *Service) ListWorkflows(ctx context.Context, workspaceID string) ([]*models.Workflow, error) { + return s.repo.ListWorkflows(ctx, workspaceID) } // Repository operations @@ -1676,7 +1762,7 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task * data := map[string]interface{}{ "task_id": task.ID, - "board_id": task.BoardID, + "workflow_id": task.WorkflowID, "workflow_step_id": task.WorkflowStepID, "title": task.Title, "description": task.Description, @@ -1754,25 +1840,25 @@ func (s *Service) publishWorkspaceEvent(ctx context.Context, eventType string, w } } -func (s *Service) publishBoardEvent(ctx context.Context, eventType string, board *models.Board) { - if s.eventBus == nil || board == nil { +func (s *Service) publishWorkflowEvent(ctx context.Context, eventType string, workflow *models.Workflow) { + if s.eventBus == nil || workflow == nil { return } data := map[string]interface{}{ - "id": board.ID, - "workspace_id": board.WorkspaceID, - "name": board.Name, - "description": board.Description, - "created_at": board.CreatedAt.Format(time.RFC3339), - "updated_at": board.UpdatedAt.Format(time.RFC3339), + "id": workflow.ID, + "workspace_id": workflow.WorkspaceID, + "name": workflow.Name, + "description": workflow.Description, + "created_at": workflow.CreatedAt.Format(time.RFC3339), + "updated_at": workflow.UpdatedAt.Format(time.RFC3339), } event := bus.NewEvent(eventType, "task-service", data) if err := s.eventBus.Publish(ctx, eventType, event); err != nil { - s.logger.Error("failed to publish board event", + s.logger.Error("failed to publish workflow event", zap.String("event_type", eventType), - zap.String("board_id", board.ID), + zap.String("workflow_id", workflow.ID), zap.Error(err)) } } diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 4449695935..2a6495ab39 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -105,16 +105,16 @@ func TestService_CreateTask(t *testing.T) { svc, eventBus, repo := createTestService(t) ctx := context.Background() - // Create board first + // Create workflow first _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"} - _ = repo.CreateBoard(ctx, board) + workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) repository := &models.Repository{ID: "repo-123", WorkspaceID: "ws-1", Name: "Test Repo"} _ = repo.CreateRepository(ctx, repository) req := &CreateTaskRequest{ WorkspaceID: "ws-1", - BoardID: "board-123", + WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task", Description: "A test task", @@ -243,8 +243,8 @@ func TestService_UpdateTask(t *testing.T) { ctx := context.Background() _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - _ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Original"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Workflow"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Original"}) eventBus.ClearEvents() newTitle := "Updated Title" @@ -273,8 +273,8 @@ func TestService_DeleteTask(t *testing.T) { ctx := context.Background() _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - _ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Workflow"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test"}) eventBus.ClearEvents() err := svc.DeleteTask(ctx, "task-123") @@ -300,11 +300,11 @@ func TestService_ListTasks(t *testing.T) { ctx := context.Background() _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - _ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Task 1"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Task 2"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Workflow"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Task 1"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Task 2"}) - tasks, err := svc.ListTasks(ctx, "board-123") + tasks, err := svc.ListTasks(ctx, "wf-123") if err != nil { t.Fatalf("failed to list tasks: %v", err) } @@ -318,8 +318,8 @@ func TestService_UpdateTaskState(t *testing.T) { ctx := context.Background() _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - _ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"}) - task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test", State: v1.TaskStateTODO} + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Workflow"}) + task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test", State: v1.TaskStateTODO} _ = repo.CreateTask(ctx, task) eventBus.ClearEvents() @@ -346,13 +346,13 @@ func TestService_MoveTask(t *testing.T) { ctx := context.Background() _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - _ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Workflow"}) - task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-todo", Title: "Test", State: v1.TaskStateTODO} + task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-todo", Title: "Test", State: v1.TaskStateTODO} _ = repo.CreateTask(ctx, task) eventBus.ClearEvents() - moved, err := svc.MoveTask(ctx, "task-123", "board-123", "step-done", 0) + moved, err := svc.MoveTask(ctx, "task-123", "wf-123", "step-done", 0) if err != nil { t.Fatalf("failed to move task: %v", err) } @@ -361,100 +361,100 @@ func TestService_MoveTask(t *testing.T) { } } -// Board tests +// Workflow tests -func TestService_CreateBoard(t *testing.T) { +func TestService_CreateWorkflow(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - req := &CreateBoardRequest{ + req := &CreateWorkflowRequest{ WorkspaceID: "ws-1", - Name: "Test Board", - Description: "A test board", + Name: "Test Workflow", + Description: "A test workflow", } - board, err := svc.CreateBoard(ctx, req) + workflow, err := svc.CreateWorkflow(ctx, req) if err != nil { - t.Fatalf("failed to create board: %v", err) + t.Fatalf("failed to create workflow: %v", err) } - if board.ID == "" { - t.Error("expected board ID to be set") + if workflow.ID == "" { + t.Error("expected workflow ID to be set") } - if board.Name != "Test Board" { - t.Errorf("expected name 'Test Board', got %s", board.Name) + if workflow.Name != "Test Workflow" { + t.Errorf("expected name 'Test Workflow', got %s", workflow.Name) } } -func TestService_GetBoard(t *testing.T) { +func TestService_GetWorkflow(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"} - _ = repo.CreateBoard(ctx, board) + workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) - retrieved, err := svc.GetBoard(ctx, "board-123") + retrieved, err := svc.GetWorkflow(ctx, "wf-123") if err != nil { - t.Fatalf("failed to get board: %v", err) + t.Fatalf("failed to get workflow: %v", err) } - if retrieved.Name != "Test Board" { - t.Errorf("expected name 'Test Board', got %s", retrieved.Name) + if retrieved.Name != "Test Workflow" { + t.Errorf("expected name 'Test Workflow', got %s", retrieved.Name) } } -func TestService_UpdateBoard(t *testing.T) { +func TestService_UpdateWorkflow(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Original"} - _ = repo.CreateBoard(ctx, board) + workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Original"} + _ = repo.CreateWorkflow(ctx, workflow) newName := "Updated" - req := &UpdateBoardRequest{Name: &newName} + req := &UpdateWorkflowRequest{Name: &newName} - updated, err := svc.UpdateBoard(ctx, "board-123", req) + updated, err := svc.UpdateWorkflow(ctx, "wf-123", req) if err != nil { - t.Fatalf("failed to update board: %v", err) + t.Fatalf("failed to update workflow: %v", err) } if updated.Name != "Updated" { t.Errorf("expected name 'Updated', got %s", updated.Name) } } -func TestService_DeleteBoard(t *testing.T) { +func TestService_DeleteWorkflow(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"} - _ = repo.CreateBoard(ctx, board) + workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) - err := svc.DeleteBoard(ctx, "board-123") + err := svc.DeleteWorkflow(ctx, "wf-123") if err != nil { - t.Fatalf("failed to delete board: %v", err) + t.Fatalf("failed to delete workflow: %v", err) } - _, err = svc.GetBoard(ctx, "board-123") + _, err = svc.GetWorkflow(ctx, "wf-123") if err == nil { - t.Error("expected board to be deleted") + t.Error("expected workflow to be deleted") } } -func TestService_ListBoards(t *testing.T) { +func TestService_ListWorkflows(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - _ = repo.CreateBoard(ctx, &models.Board{ID: "board-1", WorkspaceID: "ws-1", Name: "Board 1"}) - _ = repo.CreateBoard(ctx, &models.Board{ID: "board-2", WorkspaceID: "ws-1", Name: "Board 2"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Workflow 1"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-2", WorkspaceID: "ws-1", Name: "Workflow 2"}) - boards, err := svc.ListBoards(ctx, "ws-1") + workflows, err := svc.ListWorkflows(ctx, "ws-1") if err != nil { - t.Fatalf("failed to list boards: %v", err) + t.Fatalf("failed to list workflows: %v", err) } - if len(boards) != 2 { - t.Errorf("expected 2 boards, got %d", len(boards)) + if len(workflows) != 2 { + t.Errorf("expected 2 workflows, got %d", len(workflows)) } } @@ -466,8 +466,8 @@ func setupTestTask(t *testing.T, repo repository.Repository) { t.Helper() ctx := context.Background() _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) - _ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"}) - _ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Workflow"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Test Task"}) } func setupTestSession(t *testing.T, repo repository.Repository) string { diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 9c6d58bcad..6383a7626c 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -45,7 +45,8 @@ func (c *Controller) GetUserSettings(ctx context.Context) (dto.UserSettingsRespo func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserSettingsRequest) (dto.UserSettingsResponse, error) { settings, err := c.svc.UpdateUserSettings(ctx, &service.UpdateUserSettingsRequest{ WorkspaceID: req.WorkspaceID, - BoardID: req.BoardID, + KanbanViewMode: req.KanbanViewMode, + WorkflowFilterID: req.WorkflowFilterID, RepositoryIDs: req.RepositoryIDs, InitialSetupComplete: req.InitialSetupComplete, PreferredShell: req.PreferredShell, diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index e2b3c940e3..72904d5b44 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -16,7 +16,8 @@ type UserDTO struct { type UserSettingsDTO struct { UserID string `json:"user_id"` WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` + KanbanViewMode string `json:"kanban_view_mode"` + WorkflowFilterID string `json:"workflow_filter_id"` RepositoryIDs []string `json:"repository_ids"` InitialSetupComplete bool `json:"initial_setup_complete"` PreferredShell string `json:"preferred_shell"` @@ -47,7 +48,8 @@ type ShellOption struct { type UpdateUserSettingsRequest struct { WorkspaceID *string `json:"workspace_id,omitempty"` - BoardID *string `json:"board_id,omitempty"` + KanbanViewMode *string `json:"kanban_view_mode,omitempty"` + WorkflowFilterID *string `json:"workflow_filter_id,omitempty"` RepositoryIDs *[]string `json:"repository_ids,omitempty"` InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` PreferredShell *string `json:"preferred_shell,omitempty"` @@ -73,7 +75,8 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { return UserSettingsDTO{ UserID: settings.UserID, WorkspaceID: settings.WorkspaceID, - BoardID: settings.BoardID, + KanbanViewMode: settings.KanbanViewMode, + WorkflowFilterID: settings.WorkflowFilterID, RepositoryIDs: settings.RepositoryIDs, InitialSetupComplete: settings.InitialSetupComplete, PreferredShell: settings.PreferredShell, diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 96905b7974..9fc9ec4ec2 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -12,7 +12,8 @@ type User struct { type UserSettings struct { UserID string `json:"user_id"` WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` + KanbanViewMode string `json:"kanban_view_mode"` + WorkflowFilterID string `json:"workflow_filter_id"` RepositoryIDs []string `json:"repository_ids"` InitialSetupComplete bool `json:"initial_setup_complete"` PreferredShell string `json:"preferred_shell"` diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index cb44849c63..0b936a6372 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -27,7 +27,8 @@ type Service struct { type UpdateUserSettingsRequest struct { WorkspaceID *string - BoardID *string + KanbanViewMode *string + WorkflowFilterID *string RepositoryIDs *[]string InitialSetupComplete *bool PreferredShell *string @@ -81,8 +82,11 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting if req.WorkspaceID != nil { settings.WorkspaceID = *req.WorkspaceID } - if req.BoardID != nil { - settings.BoardID = *req.BoardID + if req.KanbanViewMode != nil { + settings.KanbanViewMode = *req.KanbanViewMode + } + if req.WorkflowFilterID != nil { + settings.WorkflowFilterID = *req.WorkflowFilterID } if req.RepositoryIDs != nil { settings.RepositoryIDs = *req.RepositoryIDs @@ -140,7 +144,8 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models data := map[string]interface{}{ "user_id": settings.UserID, "workspace_id": settings.WorkspaceID, - "board_id": settings.BoardID, + "kanban_view_mode": settings.KanbanViewMode, + "workflow_filter_id": settings.WorkflowFilterID, "repository_ids": settings.RepositoryIDs, "initial_setup_complete": settings.InitialSetupComplete, "preferred_shell": settings.PreferredShell, diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 06800633bf..caa4919cc9 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -125,7 +125,8 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod } settingsPayload, err := json.Marshal(map[string]interface{}{ "workspace_id": settings.WorkspaceID, - "board_id": settings.BoardID, + "kanban_view_mode": settings.KanbanViewMode, + "workflow_filter_id": settings.WorkflowFilterID, "repository_ids": settings.RepositoryIDs, "initial_setup_complete": settings.InitialSetupComplete, "preferred_shell": settings.PreferredShell, @@ -179,7 +180,8 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin } var payload struct { WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` + KanbanViewMode string `json:"kanban_view_mode"` + WorkflowFilterID string `json:"workflow_filter_id"` RepositoryIDs []string `json:"repository_ids"` InitialSetupComplete bool `json:"initial_setup_complete"` PreferredShell string `json:"preferred_shell"` @@ -195,7 +197,8 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin return nil, err } settings.WorkspaceID = payload.WorkspaceID - settings.BoardID = payload.BoardID + settings.KanbanViewMode = payload.KanbanViewMode + settings.WorkflowFilterID = payload.WorkflowFilterID settings.RepositoryIDs = payload.RepositoryIDs settings.InitialSetupComplete = payload.InitialSetupComplete settings.PreferredShell = payload.PreferredShell diff --git a/apps/backend/internal/workflow/controller/controller.go b/apps/backend/internal/workflow/controller/controller.go index 60039911af..ff2b0e5e43 100644 --- a/apps/backend/internal/workflow/controller/controller.go +++ b/apps/backend/internal/workflow/controller/controller.go @@ -46,7 +46,7 @@ func (c *Controller) GetTemplate(ctx context.Context, id string) (*GetTemplateRe // Step responses type ListStepsRequest struct { - BoardID string `json:"board_id"` + WorkflowID string `json:"workflow_id"` } type ListStepsResponse struct { @@ -58,12 +58,12 @@ type GetStepResponse struct { } type CreateStepsFromTemplateRequest struct { - BoardID string `json:"board_id"` + WorkflowID string `json:"workflow_id"` TemplateID string `json:"template_id"` } -func (c *Controller) ListStepsByBoard(ctx context.Context, req ListStepsRequest) (*ListStepsResponse, error) { - steps, err := c.svc.ListStepsByBoard(ctx, req.BoardID) +func (c *Controller) ListStepsByWorkflow(ctx context.Context, req ListStepsRequest) (*ListStepsResponse, error) { + steps, err := c.svc.ListStepsByWorkflow(ctx, req.WorkflowID) if err != nil { return nil, err } @@ -79,39 +79,37 @@ func (c *Controller) GetStep(ctx context.Context, id string) (*GetStepResponse, } func (c *Controller) CreateStepsFromTemplate(ctx context.Context, req CreateStepsFromTemplateRequest) error { - return c.svc.CreateStepsFromTemplate(ctx, req.BoardID, req.TemplateID) + return c.svc.CreateStepsFromTemplate(ctx, req.WorkflowID, req.TemplateID) } // CreateStepRequest is the request for creating a single workflow step. type CreateStepRequest struct { - BoardID string `json:"board_id"` - Name string `json:"name"` - StepType string `json:"step_type"` - Position int `json:"position"` - Color string `json:"color"` - AutoStartAgent bool `json:"auto_start_agent"` - PlanMode bool `json:"plan_mode"` - RequireApproval bool `json:"require_approval"` - PromptPrefix string `json:"prompt_prefix"` - PromptSuffix string `json:"prompt_suffix"` - AllowManualMove bool `json:"allow_manual_move"` + WorkflowID string `json:"workflow_id"` + Name string `json:"name"` + Position int `json:"position"` + Color string `json:"color"` + Prompt string `json:"prompt,omitempty"` + Events *models.StepEvents `json:"events,omitempty"` + AllowManualMove bool `json:"allow_manual_move"` + IsStartStep *bool `json:"is_start_step,omitempty"` } // CreateStep creates a new workflow step. func (c *Controller) CreateStep(ctx context.Context, req CreateStepRequest) (*GetStepResponse, error) { step := &models.WorkflowStep{ - BoardID: req.BoardID, + WorkflowID: req.WorkflowID, Name: req.Name, - StepType: models.StepType(req.StepType), Position: req.Position, Color: req.Color, - AutoStartAgent: req.AutoStartAgent, - PlanMode: req.PlanMode, - RequireApproval: req.RequireApproval, - PromptPrefix: req.PromptPrefix, - PromptSuffix: req.PromptSuffix, + Prompt: req.Prompt, AllowManualMove: req.AllowManualMove, } + if req.Events != nil { + step.Events = *req.Events + } + if req.IsStartStep != nil { + step.IsStartStep = *req.IsStartStep + } if err := c.svc.CreateStep(ctx, step); err != nil { return nil, err } @@ -120,17 +118,14 @@ func (c *Controller) CreateStep(ctx context.Context, req CreateStepRequest) (*Ge // UpdateStepRequest is the request for updating a workflow step. type UpdateStepRequest struct { - ID string `json:"id"` - Name *string `json:"name,omitempty"` - StepType *string `json:"step_type,omitempty"` - Position *int `json:"position,omitempty"` - Color *string `json:"color,omitempty"` - AutoStartAgent *bool `json:"auto_start_agent,omitempty"` - PlanMode *bool `json:"plan_mode,omitempty"` - RequireApproval *bool `json:"require_approval,omitempty"` - PromptPrefix *string `json:"prompt_prefix,omitempty"` - PromptSuffix *string `json:"prompt_suffix,omitempty"` - AllowManualMove *bool `json:"allow_manual_move,omitempty"` + ID string `json:"id"` + Name *string `json:"name,omitempty"` + Position *int `json:"position,omitempty"` + Color *string `json:"color,omitempty"` + Prompt *string `json:"prompt,omitempty"` + Events *models.StepEvents `json:"events,omitempty"` + AllowManualMove *bool `json:"allow_manual_move,omitempty"` + IsStartStep *bool `json:"is_start_step,omitempty"` } // UpdateStep updates an existing workflow step. @@ -142,33 +137,24 @@ func (c *Controller) UpdateStep(ctx context.Context, req UpdateStepRequest) (*Ge if req.Name != nil { step.Name = *req.Name } - if req.StepType != nil { - step.StepType = models.StepType(*req.StepType) - } if req.Position != nil { step.Position = *req.Position } if req.Color != nil { step.Color = *req.Color } - if req.AutoStartAgent != nil { - step.AutoStartAgent = *req.AutoStartAgent + if req.Prompt != nil { + step.Prompt = *req.Prompt } - if req.PlanMode != nil { - step.PlanMode = *req.PlanMode - } - if req.RequireApproval != nil { - step.RequireApproval = *req.RequireApproval - } - if req.PromptPrefix != nil { - step.PromptPrefix = *req.PromptPrefix - } - if req.PromptSuffix != nil { - step.PromptSuffix = *req.PromptSuffix + if req.Events != nil { + step.Events = *req.Events } if req.AllowManualMove != nil { step.AllowManualMove = *req.AllowManualMove } + if req.IsStartStep != nil { + step.IsStartStep = *req.IsStartStep + } if err := c.svc.UpdateStep(ctx, step); err != nil { return nil, err } @@ -182,13 +168,13 @@ func (c *Controller) DeleteStep(ctx context.Context, id string) error { // ReorderStepsRequest is the request for reordering workflow steps. type ReorderStepsRequest struct { - BoardID string `json:"board_id"` - StepIDs []string `json:"step_ids"` + WorkflowID string `json:"workflow_id"` + StepIDs []string `json:"step_ids"` } -// ReorderSteps reorders workflow steps for a board. +// ReorderSteps reorders workflow steps for a workflow. func (c *Controller) ReorderSteps(ctx context.Context, req ReorderStepsRequest) error { - return c.svc.ReorderSteps(ctx, req.BoardID, req.StepIDs) + return c.svc.ReorderSteps(ctx, req.WorkflowID, req.StepIDs) } // History responses @@ -208,4 +194,3 @@ func (c *Controller) ListHistoryBySession(ctx context.Context, req ListHistoryRe } return &ListHistoryResponse{History: history}, nil } - diff --git a/apps/backend/internal/workflow/handlers/handlers.go b/apps/backend/internal/workflow/handlers/handlers.go index 3494476a2c..4c33f19971 100644 --- a/apps/backend/internal/workflow/handlers/handlers.go +++ b/apps/backend/internal/workflow/handlers/handlers.go @@ -41,13 +41,13 @@ func (h *Handlers) registerHTTP(router *gin.Engine) { api.GET("/workflow/templates/:id", h.httpGetTemplate) // Step routes - api.GET("/boards/:id/workflow/steps", h.httpListStepsByBoard) + api.GET("/workflows/:id/workflow/steps", h.httpListStepsByWorkflow) api.GET("/workflow/steps/:id", h.httpGetStep) - api.POST("/boards/:id/workflow/steps", h.httpCreateStepsFromTemplate) + api.POST("/workflows/:id/workflow/steps", h.httpCreateStepsFromTemplate) api.POST("/workflow/steps", h.httpCreateStep) api.PUT("/workflow/steps/:id", h.httpUpdateStep) api.DELETE("/workflow/steps/:id", h.httpDeleteStep) - api.PUT("/boards/:id/workflow/steps/reorder", h.httpReorderSteps) + api.PUT("/workflows/:id/workflow/steps/reorder", h.httpReorderSteps) // History routes api.GET("/sessions/:id/workflow/history", h.httpListHistoryBySession) @@ -86,9 +86,9 @@ func (h *Handlers) httpGetTemplate(c *gin.Context) { // HTTP handlers - Steps -func (h *Handlers) httpListStepsByBoard(c *gin.Context) { - resp, err := h.controller.ListStepsByBoard(c.Request.Context(), controller.ListStepsRequest{ - BoardID: c.Param("id"), +func (h *Handlers) httpListStepsByWorkflow(c *gin.Context) { + resp, err := h.controller.ListStepsByWorkflow(c.Request.Context(), controller.ListStepsRequest{ + WorkflowID: c.Param("id"), }) if err != nil { h.logger.Error("failed to list steps", zap.Error(err)) @@ -119,7 +119,7 @@ func (h *Handlers) httpCreateStepsFromTemplate(c *gin.Context) { return } if err := h.controller.CreateStepsFromTemplate(c.Request.Context(), controller.CreateStepsFromTemplateRequest{ - BoardID: c.Param("id"), + WorkflowID: c.Param("id"), TemplateID: req.TemplateID, }); err != nil { h.logger.Error("failed to create steps from template", zap.Error(err)) @@ -135,8 +135,8 @@ func (h *Handlers) httpCreateStep(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) return } - if req.BoardID == "" || req.Name == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "board_id and name are required"}) + if req.WorkflowID == "" || req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "workflow_id and name are required"}) return } resp, err := h.controller.CreateStep(c.Request.Context(), req) @@ -184,7 +184,7 @@ func (h *Handlers) httpReorderSteps(c *gin.Context) { return } if err := h.controller.ReorderSteps(c.Request.Context(), controller.ReorderStepsRequest{ - BoardID: c.Param("id"), + WorkflowID: c.Param("id"), StepIDs: req.StepIDs, }); err != nil { h.logger.Error("failed to reorder steps", zap.Error(err)) @@ -242,7 +242,7 @@ func (h *Handlers) wsGetTemplate(ctx context.Context, msg *ws.Message) (*ws.Mess // WS handlers - Steps type wsListStepsRequest struct { - BoardID string `json:"board_id"` + WorkflowID string `json:"workflow_id"` } func (h *Handlers) wsListSteps(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -250,10 +250,10 @@ func (h *Handlers) wsListSteps(ctx context.Context, msg *ws.Message) (*ws.Messag if err := msg.ParsePayload(&req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } - if req.BoardID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil) + if req.WorkflowID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) } - resp, err := h.controller.ListStepsByBoard(ctx, controller.ListStepsRequest{BoardID: req.BoardID}) + resp, err := h.controller.ListStepsByWorkflow(ctx, controller.ListStepsRequest{WorkflowID: req.WorkflowID}) if err != nil { h.logger.Error("failed to list steps", zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list steps", nil) @@ -282,7 +282,7 @@ func (h *Handlers) wsGetStep(ctx context.Context, msg *ws.Message) (*ws.Message, } type wsCreateStepsRequest struct { - BoardID string `json:"board_id"` + WorkflowID string `json:"workflow_id"` TemplateID string `json:"template_id"` } @@ -291,11 +291,11 @@ func (h *Handlers) wsCreateStepsFromTemplate(ctx context.Context, msg *ws.Messag if err := msg.ParsePayload(&req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } - if req.BoardID == "" || req.TemplateID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id and template_id are required", nil) + if req.WorkflowID == "" || req.TemplateID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id and template_id are required", nil) } if err := h.controller.CreateStepsFromTemplate(ctx, controller.CreateStepsFromTemplateRequest{ - BoardID: req.BoardID, + WorkflowID: req.WorkflowID, TemplateID: req.TemplateID, }); err != nil { h.logger.Error("failed to create steps", zap.Error(err)) diff --git a/apps/backend/internal/workflow/models/models.go b/apps/backend/internal/workflow/models/models.go index e4047e61f6..61d0f0a277 100644 --- a/apps/backend/internal/workflow/models/models.go +++ b/apps/backend/internal/workflow/models/models.go @@ -1,24 +1,62 @@ package models import ( + "maps" "time" - - v1 "github.com/kandev/kandev/pkg/api/v1" ) -// StepType represents the semantic type of a workflow step -type StepType string +// OnEnterActionType represents the type of action to execute when entering a step. +type OnEnterActionType string const ( - StepTypeBacklog StepType = "backlog" - StepTypePlanning StepType = "planning" - StepTypeImplementation StepType = "implementation" - StepTypeReview StepType = "review" - StepTypeVerification StepType = "verification" - StepTypeDone StepType = "done" - StepTypeBlocked StepType = "blocked" + OnEnterEnablePlanMode OnEnterActionType = "enable_plan_mode" + OnEnterAutoStartAgent OnEnterActionType = "auto_start_agent" ) +// OnTurnStartActionType represents the type of action to execute when a user sends a message. +type OnTurnStartActionType string + +const ( + OnTurnStartMoveToNext OnTurnStartActionType = "move_to_next" + OnTurnStartMoveToPrevious OnTurnStartActionType = "move_to_previous" + OnTurnStartMoveToStep OnTurnStartActionType = "move_to_step" +) + +// OnTurnStartAction represents an action to execute when a user sends a message. +type OnTurnStartAction struct { + Type OnTurnStartActionType `json:"type"` + Config map[string]interface{} `json:"config,omitempty"` +} + +// OnTurnCompleteActionType represents the type of action to execute when an agent turn completes. +type OnTurnCompleteActionType string + +const ( + OnTurnCompleteMoveToNext OnTurnCompleteActionType = "move_to_next" + OnTurnCompleteMoveToPrevious OnTurnCompleteActionType = "move_to_previous" + OnTurnCompleteMoveToStep OnTurnCompleteActionType = "move_to_step" + OnTurnCompleteDisablePlanMode OnTurnCompleteActionType = "disable_plan_mode" +) + +// OnEnterAction represents an action to execute when entering a step. +type OnEnterAction struct { + Type OnEnterActionType `json:"type"` + Config map[string]interface{} `json:"config,omitempty"` +} + +// OnTurnCompleteAction represents an action to execute when an agent turn completes. +type OnTurnCompleteAction struct { + Type OnTurnCompleteActionType `json:"type"` + Config map[string]interface{} `json:"config,omitempty"` +} + +// StepEvents contains event-driven actions for a workflow step. +type StepEvents struct { + OnEnter []OnEnterAction `json:"on_enter,omitempty"` + OnTurnStart []OnTurnStartAction `json:"on_turn_start,omitempty"` + OnTurnComplete []OnTurnCompleteAction `json:"on_turn_complete,omitempty"` +} + // ReviewStatus represents the review state of a session type ReviewStatus string @@ -28,7 +66,7 @@ const ( ReviewStatusApproved ReviewStatus = "approved" ) -// WorkflowTemplate represents a pre-defined workflow type that boards can adopt +// WorkflowTemplate represents a pre-defined workflow type that workflows can adopt type WorkflowTemplate struct { ID string `json:"id"` Name string `json:"name"` @@ -41,41 +79,54 @@ type WorkflowTemplate struct { // StepDefinition represents a step in a workflow template (stored as JSON in WorkflowTemplate) type StepDefinition struct { - ID string `json:"id"` - Name string `json:"name"` - StepType StepType `json:"step_type"` - Position int `json:"position"` - Color string `json:"color"` - TaskState v1.TaskState `json:"task_state"` - AutoStartAgent bool `json:"auto_start_agent"` - PlanMode bool `json:"plan_mode"` - RequireApproval bool `json:"require_approval"` - PromptPrefix string `json:"prompt_prefix,omitempty"` - PromptSuffix string `json:"prompt_suffix,omitempty"` - OnCompleteStepID string `json:"on_complete_step_id,omitempty"` - OnApprovalStepID string `json:"on_approval_step_id,omitempty"` - AllowManualMove bool `json:"allow_manual_move"` + ID string `json:"id"` + Name string `json:"name"` + Position int `json:"position"` + Color string `json:"color"` + Prompt string `json:"prompt,omitempty"` + Events StepEvents `json:"events"` + AllowManualMove bool `json:"allow_manual_move"` + IsStartStep bool `json:"is_start_step"` } -// WorkflowStep represents a step in a board's workflow (replaces Column) +// WorkflowStep represents a step in a workflow type WorkflowStep struct { - ID string `json:"id"` - BoardID string `json:"board_id"` - Name string `json:"name"` - StepType StepType `json:"step_type"` - Position int `json:"position"` - Color string `json:"color"` - TaskState v1.TaskState `json:"task_state"` - AutoStartAgent bool `json:"auto_start_agent"` - PlanMode bool `json:"plan_mode"` - RequireApproval bool `json:"require_approval"` - PromptPrefix string `json:"prompt_prefix,omitempty"` - PromptSuffix string `json:"prompt_suffix,omitempty"` - OnCompleteStepID *string `json:"on_complete_step_id,omitempty"` - OnApprovalStepID *string `json:"on_approval_step_id,omitempty"` - AllowManualMove bool `json:"allow_manual_move"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + WorkflowID string `json:"workflow_id"` + Name string `json:"name"` + Position int `json:"position"` + Color string `json:"color"` + Prompt string `json:"prompt,omitempty"` + Events StepEvents `json:"events"` + AllowManualMove bool `json:"allow_manual_move"` + IsStartStep bool `json:"is_start_step"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// HasOnEnterAction checks if the step has a specific on_enter action type. +func (s *WorkflowStep) HasOnEnterAction(actionType OnEnterActionType) bool { + for _, action := range s.Events.OnEnter { + if action.Type == actionType { + return true + } + } + return false +} + +// HasOnTurnStartAction checks if the step has any on_turn_start actions. +func (s *WorkflowStep) HasOnTurnStartAction() bool { + return len(s.Events.OnTurnStart) > 0 +} + +// HasOnTurnCompleteAction checks if the step has a specific on_turn_complete action type. +func (s *WorkflowStep) HasOnTurnCompleteAction(actionType OnTurnCompleteActionType) bool { + for _, action := range s.Events.OnTurnComplete { + if action.Type == actionType { + return true + } + } + return false } // StepTransitionTrigger represents how a session moved between steps @@ -99,3 +150,36 @@ type SessionStepHistory struct { CreatedAt time.Time `json:"created_at"` } +// RemapStepEvents returns a copy of events with all step_id references +// in move_to_step actions replaced using the provided ID mapping. +func RemapStepEvents(events StepEvents, idMap map[string]string) StepEvents { + result := StepEvents{} + result.OnEnter = append(result.OnEnter, events.OnEnter...) + for _, a := range events.OnTurnStart { + if a.Type == OnTurnStartMoveToStep && a.Config != nil { + if stepID, ok := a.Config["step_id"].(string); ok { + if newID, found := idMap[stepID]; found { + cfg := make(map[string]any, len(a.Config)) + maps.Copy(cfg, a.Config) + cfg["step_id"] = newID + a.Config = cfg + } + } + } + result.OnTurnStart = append(result.OnTurnStart, a) + } + for _, a := range events.OnTurnComplete { + if a.Type == OnTurnCompleteMoveToStep && a.Config != nil { + if stepID, ok := a.Config["step_id"].(string); ok { + if newID, found := idMap[stepID]; found { + cfg := make(map[string]any, len(a.Config)) + maps.Copy(cfg, a.Config) + cfg["step_id"] = newID + a.Config = cfg + } + } + } + result.OnTurnComplete = append(result.OnTurnComplete, a) + } + return result +} diff --git a/apps/backend/internal/workflow/repository/sqlite.go b/apps/backend/internal/workflow/repository/sqlite.go index d1e3d041b0..3804b30c35 100644 --- a/apps/backend/internal/workflow/repository/sqlite.go +++ b/apps/backend/internal/workflow/repository/sqlite.go @@ -51,33 +51,37 @@ func (r *Repository) initSchema() error { return fmt.Errorf("failed to create workflow_templates table: %w", err) } - // Create workflow_steps table + // Create workflow_steps table with new event-driven schema stepsSchema := ` CREATE TABLE IF NOT EXISTS workflow_steps ( id TEXT PRIMARY KEY, - board_id TEXT NOT NULL, + workflow_id TEXT NOT NULL, name TEXT NOT NULL, - step_type TEXT NOT NULL, position INTEGER NOT NULL, color TEXT, - auto_start_agent INTEGER DEFAULT 0, - plan_mode INTEGER DEFAULT 0, - require_approval INTEGER DEFAULT 0, - prompt_prefix TEXT, - prompt_suffix TEXT, - on_complete_step_id TEXT, - on_approval_step_id TEXT, + prompt TEXT, + events TEXT, allow_manual_move INTEGER DEFAULT 1, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, - FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE + FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE ); - CREATE INDEX IF NOT EXISTS idx_workflow_steps_board ON workflow_steps(board_id); + CREATE INDEX IF NOT EXISTS idx_workflow_steps_workflow ON workflow_steps(workflow_id); ` if _, err := r.db.Exec(stepsSchema); err != nil { return fmt.Errorf("failed to create workflow_steps table: %w", err) } + // Migrate old schema to new schema if needed + if err := r.migrateWorkflowSteps(); err != nil { + return fmt.Errorf("failed to migrate workflow_steps: %w", err) + } + + // Add is_start_step column if not present + if err := r.addIsStartStepColumn(); err != nil { + return fmt.Errorf("failed to add is_start_step column: %w", err) + } + // Create session_step_history table historySchema := ` CREATE TABLE IF NOT EXISTS session_step_history ( @@ -102,7 +106,7 @@ func (r *Repository) initSchema() error { return fmt.Errorf("failed to seed system templates: %w", err) } - // Seed default workflow steps for boards that don't have any + // Seed default workflow steps for workflows that don't have any if err := r.seedDefaultWorkflowSteps(); err != nil { return fmt.Errorf("failed to seed default workflow steps: %w", err) } @@ -110,13 +114,235 @@ func (r *Repository) initSchema() error { return nil } -// seedDefaultWorkflowSteps creates default workflow steps for boards that don't have any. +// migrateWorkflowSteps detects old schema (has step_type column) and migrates data to new schema. +func (r *Repository) migrateWorkflowSteps() error { + // Check if old column exists + var hasStepType bool + rows, err := r.db.Query("PRAGMA table_info(workflow_steps)") + if err != nil { + return err + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var cid int + var name, colType string + var notNull int + var dfltValue sql.NullString + var pk int + if err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk); err != nil { + return err + } + if name == "step_type" { + hasStepType = true + break + } + } + if err := rows.Err(); err != nil { + return err + } + + if !hasStepType { + return nil // Already on new schema + } + + // Migrate: read old data, recreate table with new schema, insert converted data + type oldStep struct { + ID string + WorkflowID string + Name string + Position int + Color sql.NullString + AutoStartAgent int + PlanMode int + RequireApproval int + PromptPrefix sql.NullString + PromptSuffix sql.NullString + OnCompleteStepID sql.NullString + OnApprovalStepID sql.NullString + AllowManualMove int + CreatedAt time.Time + UpdatedAt time.Time + } + + oldRows, err := r.db.Query(` + SELECT id, workflow_id, name, position, color, + auto_start_agent, plan_mode, require_approval, + prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id, + allow_manual_move, created_at, updated_at + FROM workflow_steps + `) + if err != nil { + return fmt.Errorf("failed to read old workflow_steps: %w", err) + } + defer func() { _ = oldRows.Close() }() + + var oldSteps []oldStep + for oldRows.Next() { + var s oldStep + if err := oldRows.Scan(&s.ID, &s.WorkflowID, &s.Name, &s.Position, &s.Color, + &s.AutoStartAgent, &s.PlanMode, &s.RequireApproval, + &s.PromptPrefix, &s.PromptSuffix, &s.OnCompleteStepID, &s.OnApprovalStepID, + &s.AllowManualMove, &s.CreatedAt, &s.UpdatedAt); err != nil { + return fmt.Errorf("failed to scan old workflow step: %w", err) + } + oldSteps = append(oldSteps, s) + } + if err := oldRows.Err(); err != nil { + return err + } + + // Drop old table and recreate within a transaction for atomicity + tx, err := r.db.Begin() + if err != nil { + return fmt.Errorf("failed to begin migration transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Exec("DROP TABLE workflow_steps"); err != nil { + return fmt.Errorf("failed to drop old workflow_steps table: %w", err) + } + + newSchema := ` + CREATE TABLE workflow_steps ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL, + name TEXT NOT NULL, + position INTEGER NOT NULL, + color TEXT, + prompt TEXT, + events TEXT, + allow_manual_move INTEGER DEFAULT 1, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_workflow_steps_workflow ON workflow_steps(workflow_id); + ` + if _, err := tx.Exec(newSchema); err != nil { + return fmt.Errorf("failed to recreate workflow_steps table: %w", err) + } + + // Convert and insert old data + for _, s := range oldSteps { + events := convertOldStepToEvents(s.AutoStartAgent == 1, s.PlanMode == 1, s.RequireApproval == 1, + s.OnCompleteStepID, s.OnApprovalStepID) + eventsJSON, err := json.Marshal(events) + if err != nil { + return fmt.Errorf("failed to marshal events for step %s: %w", s.ID, err) + } + + // Combine old prompt prefix/suffix into single prompt + prompt := "" + if s.PromptPrefix.Valid && s.PromptPrefix.String != "" { + prompt = s.PromptPrefix.String + "\n\n{{task_prompt}}" + if s.PromptSuffix.Valid && s.PromptSuffix.String != "" { + prompt += "\n\n" + s.PromptSuffix.String + } + } else if s.PromptSuffix.Valid && s.PromptSuffix.String != "" { + prompt = "{{task_prompt}}\n\n" + s.PromptSuffix.String + } + + color := "" + if s.Color.Valid { + color = s.Color.String + } + + if _, err := tx.Exec(` + INSERT INTO workflow_steps (id, workflow_id, name, position, color, prompt, events, allow_manual_move, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, s.ID, s.WorkflowID, s.Name, s.Position, color, prompt, string(eventsJSON), s.AllowManualMove, s.CreatedAt, s.UpdatedAt); err != nil { + return fmt.Errorf("failed to insert migrated step %s: %w", s.ID, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit migration transaction: %w", err) + } + + return nil +} + +// addIsStartStepColumn adds the is_start_step column to workflow_steps if it doesn't exist. +func (r *Repository) addIsStartStepColumn() error { + rows, err := r.db.Query("PRAGMA table_info(workflow_steps)") + if err != nil { + return err + } + defer func() { _ = rows.Close() }() + + var hasColumn bool + for rows.Next() { + var cid int + var name, colType string + var notNull int + var dfltValue sql.NullString + var pk int + if err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk); err != nil { + return err + } + if name == "is_start_step" { + hasColumn = true + break + } + } + if err := rows.Err(); err != nil { + return err + } + if hasColumn { + return nil + } + + _, err = r.db.Exec("ALTER TABLE workflow_steps ADD COLUMN is_start_step INTEGER DEFAULT 0") + return err +} + +// convertOldStepToEvents converts old boolean/pointer fields to new event-driven model. +func convertOldStepToEvents(autoStart, planMode, requireApproval bool, onCompleteStepID, onApprovalStepID sql.NullString) models.StepEvents { + events := models.StepEvents{} + + // On Enter actions + if planMode { + events.OnEnter = append(events.OnEnter, models.OnEnterAction{Type: models.OnEnterEnablePlanMode}) + } + if autoStart { + events.OnEnter = append(events.OnEnter, models.OnEnterAction{Type: models.OnEnterAutoStartAgent}) + } + + // On Turn Complete actions + if planMode { + events.OnTurnComplete = append(events.OnTurnComplete, models.OnTurnCompleteAction{Type: models.OnTurnCompleteDisablePlanMode}) + } + + if !requireApproval { + // If not requiring approval and has on_complete, add move transition + if onCompleteStepID.Valid && onCompleteStepID.String != "" { + events.OnTurnComplete = append(events.OnTurnComplete, models.OnTurnCompleteAction{ + Type: models.OnTurnCompleteMoveToStep, + Config: map[string]interface{}{"step_id": onCompleteStepID.String}, + }) + } + } else if onApprovalStepID.Valid && onApprovalStepID.String != "" { + // Require approval before transitioning to the approval step + events.OnTurnComplete = append(events.OnTurnComplete, models.OnTurnCompleteAction{ + Type: models.OnTurnCompleteMoveToStep, + Config: map[string]interface{}{ + "step_id": onApprovalStepID.String, + "requires_approval": true, + }, + }) + } + + return events +} + +// seedDefaultWorkflowSteps creates default workflow steps for workflows that don't have any. // Uses the simple template as the default workflow. func (r *Repository) seedDefaultWorkflowSteps() error { - // Find boards without workflow steps + // Find workflows without workflow steps rows, err := r.db.Query(` - SELECT b.id FROM boards b - LEFT JOIN workflow_steps ws ON ws.board_id = b.id + SELECT w.id FROM workflows w + LEFT JOIN workflow_steps ws ON ws.workflow_id = w.id WHERE ws.id IS NULL `) if err != nil { @@ -124,13 +350,13 @@ func (r *Repository) seedDefaultWorkflowSteps() error { } defer func() { _ = rows.Close() }() - var boardIDs []string + var workflowIDs []string for rows.Next() { - var boardID string - if err := rows.Scan(&boardID); err != nil { + var workflowID string + if err := rows.Scan(&workflowID); err != nil { return err } - boardIDs = append(boardIDs, boardID) + workflowIDs = append(workflowIDs, workflowID) } if err := rows.Err(); err != nil { return err @@ -138,38 +364,31 @@ func (r *Repository) seedDefaultWorkflowSteps() error { // Use the simple template for default workflow steps now := time.Now() - simpleTemplate := r.getSimpleTemplate(now) + simpleTemplate := r.getKanbanTemplate(now) - for _, boardID := range boardIDs { - // Map template step IDs to generated UUIDs for linking - idMap := make(map[string]string) + for _, workflowID := range workflowIDs { + // Build mapping from template step ID to new UUID + idMap := make(map[string]string, len(simpleTemplate.Steps)) for _, stepDef := range simpleTemplate.Steps { idMap[stepDef.ID] = uuid.New().String() } for _, stepDef := range simpleTemplate.Steps { - stepID := idMap[stepDef.ID] - - // Map OnCompleteStepID if set - var onCompleteStepID *string - if stepDef.OnCompleteStepID != "" { - if mappedID, ok := idMap[stepDef.OnCompleteStepID]; ok { - onCompleteStepID = &mappedID - } + events := models.RemapStepEvents(stepDef.Events, idMap) + eventsJSON, err := json.Marshal(events) + if err != nil { + return fmt.Errorf("failed to marshal events: %w", err) } if _, err := r.db.Exec(` INSERT INTO workflow_steps ( - id, board_id, name, step_type, position, color, - auto_start_agent, plan_mode, require_approval, - prompt_prefix, prompt_suffix, on_complete_step_id, - allow_manual_move, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + id, workflow_id, name, position, color, + prompt, events, allow_manual_move, is_start_step, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, - stepID, boardID, stepDef.Name, string(stepDef.StepType), stepDef.Position, stepDef.Color, - stepDef.AutoStartAgent, stepDef.PlanMode, stepDef.RequireApproval, - stepDef.PromptPrefix, stepDef.PromptSuffix, onCompleteStepID, - stepDef.AllowManualMove, now, now, + idMap[stepDef.ID], workflowID, stepDef.Name, stepDef.Position, stepDef.Color, + stepDef.Prompt, string(eventsJSON), sqlite.BoolToInt(stepDef.AllowManualMove), + sqlite.BoolToInt(stepDef.IsStartStep), now, now, ); err != nil { return err } @@ -184,16 +403,7 @@ func (r *Repository) seedSystemTemplates() error { templates := r.getSystemTemplates() for _, tmpl := range templates { - // Check if template already exists - var exists bool - err := r.db.QueryRow("SELECT 1 FROM workflow_templates WHERE id = ?", tmpl.ID).Scan(&exists) - if err != nil && err != sql.ErrNoRows { - return err - } - if exists { - continue - } - + // Always upsert system templates to keep them current stepsJSON, err := json.Marshal(tmpl.Steps) if err != nil { return fmt.Errorf("failed to marshal steps for template %s: %w", tmpl.ID, err) @@ -202,9 +412,14 @@ func (r *Repository) seedSystemTemplates() error { _, err = r.db.Exec(` INSERT INTO workflow_templates (id, name, description, is_system, steps, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + steps = excluded.steps, + updated_at = excluded.updated_at `, tmpl.ID, tmpl.Name, tmpl.Description, sqlite.BoolToInt(tmpl.IsSystem), string(stepsJSON), tmpl.CreatedAt, tmpl.UpdatedAt) if err != nil { - return fmt.Errorf("failed to insert template %s: %w", tmpl.ID, err) + return fmt.Errorf("failed to upsert template %s: %w", tmpl.ID, err) } } @@ -215,9 +430,10 @@ func (r *Repository) seedSystemTemplates() error { func (r *Repository) getSystemTemplates() []*models.WorkflowTemplate { now := time.Now().UTC() return []*models.WorkflowTemplate{ - r.getSimpleTemplate(now), + r.getKanbanTemplate(now), r.getStandardTemplate(now), r.getArchitectureTemplate(now), + r.getPRReviewTemplate(now), } } @@ -227,7 +443,7 @@ func (r *Repository) getStandardTemplate(now time.Time) *models.WorkflowTemplate return &models.WorkflowTemplate{ ID: "standard", Name: "Plan & Build", - Description: "Plan first, then build: Todo → Plan → Implementation → Done", + Description: "Two-phase workflow: the agent first creates a plan for your review, then implements it. Ideal for tasks that benefit from upfront design.", IsSystem: true, CreatedAt: now, UpdatedAt: now, @@ -235,48 +451,48 @@ func (r *Repository) getStandardTemplate(now time.Time) *models.WorkflowTemplate { ID: "todo", Name: "Todo", - StepType: models.StepTypeBacklog, Position: 0, Color: "bg-neutral-400", - TaskState: "TODO", - AutoStartAgent: false, + Events: models.StepEvents{}, AllowManualMove: true, }, { - ID: "plan", - Name: "Plan", - StepType: models.StepTypePlanning, - Position: 1, - Color: "bg-purple-500", - TaskState: "IN_PROGRESS", - AutoStartAgent: true, - PlanMode: true, - RequireApproval: true, - PromptPrefix: "[PLANNING PHASE]\nAnalyze this task and create a detailed implementation plan.\nDo NOT make any code changes yet - only analyze and plan.\n\nBefore creating the plan, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nCreate a plan that includes:\n1. Understanding of the requirements\n2. Files that need to be modified or created\n3. Step-by-step implementation approach\n4. Potential risks or considerations\n\nWhen including diagrams in your plan (architecture, sequence, flowcharts, etc.), always use mermaid syntax in code blocks.\n\nIMPORTANT: Save your plan using the create_task_plan_kandev MCP tool with the task_id provided in the session context.\nAfter saving the plan, STOP and wait for user review. The user will review your plan in the UI and may edit it.\nDo not create any other files during this phase - only use the MCP tool to save the plan.", - OnCompleteStepID: "implementation", - AllowManualMove: true, + ID: "plan", + Name: "Plan", + Position: 1, + Color: "bg-purple-500", + IsStartStep: true, + Prompt: "[PLANNING PHASE]\nAnalyze this task and create a detailed implementation plan.\nDo NOT make any code changes yet - only analyze and plan.\n\nBefore creating the plan, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nCreate a plan that includes:\n1. Understanding of the requirements\n2. Files that need to be modified or created\n3. Step-by-step implementation approach\n4. Potential risks or considerations\n\nWhen including diagrams in your plan (architecture, sequence, flowcharts, etc.), always use mermaid syntax in code blocks.\n\nIMPORTANT: Save your plan using the create_task_plan_kandev MCP tool with the task_id provided in the session context.\nAfter saving the plan, STOP and wait for user review. The user will review your plan in the UI and may edit it.\nDo not create any other files during this phase - only use the MCP tool to save the plan.\n\n{{task_prompt}}", + Events: models.StepEvents{ + OnEnter: []models.OnEnterAction{ + {Type: models.OnEnterEnablePlanMode}, + {Type: models.OnEnterAutoStartAgent}, + }, + }, + AllowManualMove: true, }, { - ID: "implementation", - Name: "Implementation", - StepType: models.StepTypeImplementation, - Position: 2, - Color: "bg-blue-500", - TaskState: "IN_PROGRESS", - AutoStartAgent: true, - RequireApproval: true, - PromptPrefix: "[IMPLEMENTATION PHASE]\nBefore starting implementation, retrieve the task plan using get_task_plan_kandev with the task_id from the session context.\nReview the plan carefully, including any edits the user may have made.\nAcknowledge the plan and any user modifications before proceeding.\n\nThen implement the task following the plan step-by-step.\nYou can update the plan during implementation using update_task_plan_kandev if needed.", - OnCompleteStepID: "done", - AllowManualMove: true, + ID: "implementation", + Name: "Implementation", + Position: 2, + Color: "bg-blue-500", + Prompt: "[IMPLEMENTATION PHASE]\nBefore starting implementation, retrieve the task plan using get_task_plan_kandev with the task_id from the session context.\nReview the plan carefully, including any edits the user may have made.\nAcknowledge the plan and any user modifications before proceeding.\n\nThen implement the task following the plan step-by-step.\nYou can update the plan during implementation using update_task_plan_kandev if needed.\n\n{{task_prompt}}", + Events: models.StepEvents{ + OnEnter: []models.OnEnterAction{ + {Type: models.OnEnterAutoStartAgent}, + }, + OnTurnComplete: []models.OnTurnCompleteAction{ + {Type: models.OnTurnCompleteMoveToNext}, + }, + }, + AllowManualMove: true, }, { ID: "done", Name: "Done", - StepType: models.StepTypeDone, Position: 3, Color: "bg-green-500", - TaskState: "COMPLETED", - AutoStartAgent: false, + Events: models.StepEvents{}, AllowManualMove: true, }, }, @@ -288,7 +504,7 @@ func (r *Repository) getArchitectureTemplate(now time.Time) *models.WorkflowTemp return &models.WorkflowTemplate{ ID: "architecture", Name: "Architecture", - Description: "Design and review: Ideas → Planning → Review → Approved", + Description: "Focus on architecture and design. The agent creates technical designs for your review before any implementation begins.", IsSystem: true, CreatedAt: now, UpdatedAt: now, @@ -296,102 +512,120 @@ func (r *Repository) getArchitectureTemplate(now time.Time) *models.WorkflowTemp { ID: "backlog", Name: "Ideas", - StepType: models.StepTypeBacklog, Position: 0, Color: "bg-neutral-400", - TaskState: "TODO", - AutoStartAgent: false, + Events: models.StepEvents{}, AllowManualMove: true, }, { - ID: "planning", - Name: "Planning", - StepType: models.StepTypePlanning, - Position: 1, - Color: "bg-purple-500", - TaskState: "IN_PROGRESS", - AutoStartAgent: true, - PlanMode: true, - PromptPrefix: "[ARCHITECTURE PHASE]\nAnalyze and design the architecture for this task.\n\nBefore creating the design, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nWhen including diagrams in your design (architecture, sequence, flowcharts, etc.), always use mermaid syntax in code blocks.\n\nIMPORTANT: Save your design using the create_task_plan_kandev MCP tool with the task_id provided in the session context.\nAfter saving the plan, STOP and wait for user review. The user will review your design in the UI and may edit it.\nDo not create any other files during this phase - only use the MCP tool to save the plan.", - OnCompleteStepID: "review", - AllowManualMove: true, + ID: "planning", + Name: "Planning", + Position: 1, + Color: "bg-purple-500", + IsStartStep: true, + Prompt: "[ARCHITECTURE PHASE]\nAnalyze and design the architecture for this task.\n\nBefore creating the design, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nWhen including diagrams in your design (architecture, sequence, flowcharts, etc.), always use mermaid syntax in code blocks.\n\nIMPORTANT: Save your design using the create_task_plan_kandev MCP tool with the task_id provided in the session context.\nAfter saving the plan, STOP and wait for user review. The user will review your design in the UI and may edit it.\nDo not create any other files during this phase - only use the MCP tool to save the plan.\n\n{{task_prompt}}", + Events: models.StepEvents{ + OnEnter: []models.OnEnterAction{ + {Type: models.OnEnterEnablePlanMode}, + {Type: models.OnEnterAutoStartAgent}, + }, + OnTurnComplete: []models.OnTurnCompleteAction{ + {Type: models.OnTurnCompleteDisablePlanMode}, + {Type: models.OnTurnCompleteMoveToNext}, + }, + }, + AllowManualMove: true, }, { - ID: "review", - Name: "Review", - StepType: models.StepTypeReview, - Position: 2, - Color: "bg-yellow-500", - TaskState: "REVIEW", - AutoStartAgent: false, - RequireApproval: true, - OnApprovalStepID: "done", - AllowManualMove: true, + ID: "review", + Name: "Review", + Position: 2, + Color: "bg-yellow-500", + Events: models.StepEvents{ + OnEnter: []models.OnEnterAction{ + {Type: models.OnEnterEnablePlanMode}, + }, + OnTurnStart: []models.OnTurnStartAction{ + {Type: models.OnTurnStartMoveToPrevious}, + }, + }, + AllowManualMove: true, }, { ID: "done", Name: "Approved", - StepType: models.StepTypeDone, Position: 3, Color: "bg-green-500", - TaskState: "COMPLETED", - AutoStartAgent: false, + Events: models.StepEvents{}, AllowManualMove: true, }, }, } } -// getSimpleTemplate returns the simple workflow template. -func (r *Repository) getSimpleTemplate(now time.Time) *models.WorkflowTemplate { +// getKanbanTemplate returns the kanban workflow template. +func (r *Repository) getKanbanTemplate(now time.Time) *models.WorkflowTemplate { return &models.WorkflowTemplate{ ID: "simple", - Name: "Simple", - Description: "Quick start: Backlog → In Progress → Review → Done", + Name: "Kanban", + Description: "Classic board with automated agent work. Tasks start in In Progress where the agent runs automatically, then move to Review when done.", IsSystem: true, CreatedAt: now, UpdatedAt: now, Steps: []models.StepDefinition{ { - ID: "backlog", - Name: "Backlog", - StepType: models.StepTypeBacklog, - Position: 0, - Color: "bg-neutral-400", - TaskState: "TODO", - AutoStartAgent: false, + ID: "backlog", + Name: "Backlog", + Position: 0, + Color: "bg-neutral-400", + Events: models.StepEvents{ + OnTurnStart: []models.OnTurnStartAction{ + {Type: models.OnTurnStartMoveToNext}, + }, + OnTurnComplete: []models.OnTurnCompleteAction{ + {Type: models.OnTurnCompleteMoveToStep, Config: map[string]interface{}{"step_id": "review"}}, + }, + }, AllowManualMove: true, }, { - ID: "in-progress", - Name: "In Progress", - StepType: models.StepTypeImplementation, - Position: 1, - Color: "bg-blue-500", - TaskState: "IN_PROGRESS", - AutoStartAgent: true, - OnCompleteStepID: "review", - AllowManualMove: true, + ID: "in-progress", + Name: "In Progress", + Position: 1, + Color: "bg-blue-500", + Events: models.StepEvents{ + OnEnter: []models.OnEnterAction{ + {Type: models.OnEnterAutoStartAgent}, + }, + OnTurnComplete: []models.OnTurnCompleteAction{ + {Type: models.OnTurnCompleteMoveToStep, Config: map[string]interface{}{"step_id": "review"}}, + }, + }, + AllowManualMove: true, + IsStartStep: true, }, { - ID: "review", - Name: "Review", - StepType: models.StepTypeReview, - Position: 2, - Color: "bg-yellow-500", - TaskState: "REVIEW", - AutoStartAgent: false, - RequireApproval: false, - AllowManualMove: true, + ID: "review", + Name: "Review", + Position: 2, + Color: "bg-yellow-500", + Events: models.StepEvents{ + OnTurnStart: []models.OnTurnStartAction{ + {Type: models.OnTurnStartMoveToPrevious}, + }, + }, + AllowManualMove: true, }, { - ID: "done", - Name: "Done", - StepType: models.StepTypeDone, - Position: 3, - Color: "bg-green-500", - TaskState: "COMPLETED", - AutoStartAgent: false, + ID: "done", + Name: "Done", + Position: 3, + Color: "bg-green-500", + Events: models.StepEvents{ + OnTurnStart: []models.OnTurnStartAction{ + {Type: models.OnTurnStartMoveToStep, Config: map[string]interface{}{"step_id": "in-progress"}}, + }, + }, AllowManualMove: true, }, }, @@ -399,6 +633,61 @@ func (r *Repository) getSimpleTemplate(now time.Time) *models.WorkflowTemplate { } +// getPRReviewTemplate returns the PR review workflow template. +func (r *Repository) getPRReviewTemplate(now time.Time) *models.WorkflowTemplate { + return &models.WorkflowTemplate{ + ID: "pr-review", + Name: "PR Review", + Description: "Track pull requests through review. PRs wait in queue, get reviewed by the agent, then marked as done.", + IsSystem: true, + CreatedAt: now, + UpdatedAt: now, + Steps: []models.StepDefinition{ + { + ID: "waiting", + Name: "Waiting", + Position: 0, + Color: "bg-neutral-400", + IsStartStep: true, + Events: models.StepEvents{ + OnTurnStart: []models.OnTurnStartAction{ + {Type: models.OnTurnStartMoveToNext}, + }, + }, + AllowManualMove: true, + }, + { + ID: "review", + Name: "Review", + Position: 1, + Color: "bg-yellow-500", + Prompt: "Please review the changed files in the current git worktree.\n\nSTEP 1: Determine what to review\n- First, check if there are any uncommitted changes (dirty working directory)\n- If there are uncommitted/staged changes: review those files\n- If the working directory is clean: review the commits that have diverged from the master/main branch\n\nSTEP 2: Review the changes, then output your findings in EXACTLY 4 sections: BUG, IMPROVEMENT, NITPICK, PERFORMANCE.\n\nRules:\n- Each section is OPTIONAL — only include it if you have findings for that category\n- If a section has no findings, omit it entirely\n- Format each finding as: filename:line_number - Description\n- Be specific and reference exact line numbers\n- Keep descriptions concise but actionable\n\n{{task_prompt}}", + Events: models.StepEvents{ + OnEnter: []models.OnEnterAction{ + {Type: models.OnEnterAutoStartAgent}, + }, + OnTurnComplete: []models.OnTurnCompleteAction{ + {Type: models.OnTurnCompleteMoveToNext}, + }, + }, + AllowManualMove: true, + }, + { + ID: "done", + Name: "Done", + Position: 2, + Color: "bg-green-500", + Events: models.StepEvents{ + OnTurnStart: []models.OnTurnStartAction{ + {Type: models.OnTurnStartMoveToPrevious}, + }, + }, + AllowManualMove: true, + }, + }, + } +} + // ============================================================================ // WorkflowTemplate CRUD Operations // ============================================================================ @@ -574,65 +863,71 @@ func (r *Repository) CreateStep(ctx context.Context, step *models.WorkflowStep) step.CreatedAt = now step.UpdatedAt = now - _, err := r.db.ExecContext(ctx, ` + eventsJSON, err := json.Marshal(step.Events) + if err != nil { + return fmt.Errorf("failed to marshal events: %w", err) + } + + _, err = r.db.ExecContext(ctx, ` INSERT INTO workflow_steps ( - id, board_id, name, step_type, position, color, - auto_start_agent, plan_mode, require_approval, - prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id, - allow_manual_move, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, step.ID, step.BoardID, step.Name, step.StepType, step.Position, step.Color, - sqlite.BoolToInt(step.AutoStartAgent), sqlite.BoolToInt(step.PlanMode), sqlite.BoolToInt(step.RequireApproval), - step.PromptPrefix, step.PromptSuffix, step.OnCompleteStepID, step.OnApprovalStepID, - sqlite.BoolToInt(step.AllowManualMove), step.CreatedAt, step.UpdatedAt) + id, workflow_id, name, position, color, + prompt, events, allow_manual_move, is_start_step, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, step.ID, step.WorkflowID, step.Name, step.Position, step.Color, + step.Prompt, string(eventsJSON), sqlite.BoolToInt(step.AllowManualMove), + sqlite.BoolToInt(step.IsStartStep), step.CreatedAt, step.UpdatedAt) return err } +// scanStep scans a single workflow step row including JSON events parsing. +func (r *Repository) scanStep(row interface { + Scan(dest ...interface{}) error +}) (*models.WorkflowStep, error) { + step := &models.WorkflowStep{} + var allowManualMove, isStartStep int + var color, prompt, eventsJSON sql.NullString + + err := row.Scan(&step.ID, &step.WorkflowID, &step.Name, &step.Position, &color, + &prompt, &eventsJSON, &allowManualMove, &isStartStep, &step.CreatedAt, &step.UpdatedAt) + + if err != nil { + return nil, err + } + + step.AllowManualMove = allowManualMove == 1 + step.IsStartStep = isStartStep == 1 + if color.Valid { + step.Color = color.String + } + if prompt.Valid { + step.Prompt = prompt.String + } + if eventsJSON.Valid && eventsJSON.String != "" { + if err := json.Unmarshal([]byte(eventsJSON.String), &step.Events); err != nil { + return nil, fmt.Errorf("failed to unmarshal events: %w", err) + } + } + + return step, nil +} + +const stepSelectColumns = `id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, created_at, updated_at` + // GetStep retrieves a workflow step by ID. func (r *Repository) GetStep(ctx context.Context, id string) (*models.WorkflowStep, error) { - step := &models.WorkflowStep{} - var autoStartAgent, planMode, requireApproval, allowManualMove int - var onCompleteStepID, onApprovalStepID, promptPrefix, promptSuffix, color sql.NullString - - err := r.db.QueryRowContext(ctx, ` - SELECT id, board_id, name, step_type, position, color, - auto_start_agent, plan_mode, require_approval, - prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id, - allow_manual_move, created_at, updated_at + row := r.db.QueryRowContext(ctx, ` + SELECT `+stepSelectColumns+` FROM workflow_steps WHERE id = ? - `, id).Scan(&step.ID, &step.BoardID, &step.Name, &step.StepType, &step.Position, &color, - &autoStartAgent, &planMode, &requireApproval, - &promptPrefix, &promptSuffix, &onCompleteStepID, &onApprovalStepID, - &allowManualMove, &step.CreatedAt, &step.UpdatedAt) + `, id) + step, err := r.scanStep(row) if err == sql.ErrNoRows { return nil, fmt.Errorf("workflow step not found: %s", id) } if err != nil { return nil, err } - - step.AutoStartAgent = autoStartAgent == 1 - step.PlanMode = planMode == 1 - step.RequireApproval = requireApproval == 1 - step.AllowManualMove = allowManualMove == 1 - if color.Valid { - step.Color = color.String - } - if promptPrefix.Valid { - step.PromptPrefix = promptPrefix.String - } - if promptSuffix.Valid { - step.PromptSuffix = promptSuffix.String - } - if onCompleteStepID.Valid { - step.OnCompleteStepID = &onCompleteStepID.String - } - if onApprovalStepID.Valid { - step.OnApprovalStepID = &onApprovalStepID.String - } - return step, nil } @@ -640,17 +935,20 @@ func (r *Repository) GetStep(ctx context.Context, id string) (*models.WorkflowSt func (r *Repository) UpdateStep(ctx context.Context, step *models.WorkflowStep) error { step.UpdatedAt = time.Now().UTC() + eventsJSON, err := json.Marshal(step.Events) + if err != nil { + return fmt.Errorf("failed to marshal events: %w", err) + } + result, err := r.db.ExecContext(ctx, ` UPDATE workflow_steps SET - name = ?, step_type = ?, position = ?, color = ?, - auto_start_agent = ?, plan_mode = ?, require_approval = ?, - prompt_prefix = ?, prompt_suffix = ?, on_complete_step_id = ?, on_approval_step_id = ?, - allow_manual_move = ?, updated_at = ? + name = ?, position = ?, color = ?, + prompt = ?, events = ?, + allow_manual_move = ?, is_start_step = ?, updated_at = ? WHERE id = ? - `, step.Name, step.StepType, step.Position, step.Color, - sqlite.BoolToInt(step.AutoStartAgent), sqlite.BoolToInt(step.PlanMode), sqlite.BoolToInt(step.RequireApproval), - step.PromptPrefix, step.PromptSuffix, step.OnCompleteStepID, step.OnApprovalStepID, - sqlite.BoolToInt(step.AllowManualMove), step.UpdatedAt, step.ID) + `, step.Name, step.Position, step.Color, + step.Prompt, string(eventsJSON), + sqlite.BoolToInt(step.AllowManualMove), sqlite.BoolToInt(step.IsStartStep), step.UpdatedAt, step.ID) if err != nil { return err } @@ -662,26 +960,60 @@ func (r *Repository) UpdateStep(ctx context.Context, step *models.WorkflowStep) return nil } -// ClearStepReferences clears any OnCompleteStepID or OnApprovalStepID references -// to the given step ID within the specified board. This should be called before -// deleting a step to prevent dangling references. -func (r *Repository) ClearStepReferences(ctx context.Context, boardID, stepID string) error { +// ClearStartStepFlag clears the is_start_step flag for all steps in a workflow except the given step. +func (r *Repository) ClearStartStepFlag(ctx context.Context, workflowID, exceptStepID string) error { _, err := r.db.ExecContext(ctx, ` - UPDATE workflow_steps - SET on_complete_step_id = NULL, updated_at = ? - WHERE board_id = ? AND on_complete_step_id = ? - `, time.Now().UTC(), boardID, stepID) + UPDATE workflow_steps SET is_start_step = 0 + WHERE workflow_id = ? AND id != ? + `, workflowID, exceptStepID) + return err +} + +// GetStartStep returns the step marked as is_start_step for a workflow. +// Returns nil if no step is marked. +func (r *Repository) GetStartStep(ctx context.Context, workflowID string) (*models.WorkflowStep, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT `+stepSelectColumns+` + FROM workflow_steps WHERE workflow_id = ? AND is_start_step = 1 + LIMIT 1 + `, workflowID) + + step, err := r.scanStep(row) + if err == sql.ErrNoRows { + return nil, nil + } if err != nil { - return fmt.Errorf("failed to clear on_complete_step_id references: %w", err) + return nil, err + } + return step, nil +} + +// ClearStepReferences clears any move_to_step event config references +// to the given step ID within the specified workflow. +func (r *Repository) ClearStepReferences(ctx context.Context, workflowID, stepID string) error { + // Load all steps in the workflow and update any that reference the deleted step + steps, err := r.ListStepsByWorkflow(ctx, workflowID) + if err != nil { + return err } - _, err = r.db.ExecContext(ctx, ` - UPDATE workflow_steps - SET on_approval_step_id = NULL, updated_at = ? - WHERE board_id = ? AND on_approval_step_id = ? - `, time.Now().UTC(), boardID, stepID) - if err != nil { - return fmt.Errorf("failed to clear on_approval_step_id references: %w", err) + for _, step := range steps { + modified := false + for i, action := range step.Events.OnTurnComplete { + if action.Type == models.OnTurnCompleteMoveToStep && action.Config != nil { + if refID, ok := action.Config["step_id"].(string); ok && refID == stepID { + // Remove this action + step.Events.OnTurnComplete = append(step.Events.OnTurnComplete[:i], step.Events.OnTurnComplete[i+1:]...) + modified = true + break + } + } + } + if modified { + if err := r.UpdateStep(ctx, step); err != nil { + return fmt.Errorf("failed to clear step reference from step %s: %w", step.ID, err) + } + } } return nil @@ -701,15 +1033,12 @@ func (r *Repository) DeleteStep(ctx context.Context, id string) error { return nil } -// ListStepsByBoard returns all workflow steps for a board. -func (r *Repository) ListStepsByBoard(ctx context.Context, boardID string) ([]*models.WorkflowStep, error) { +// ListStepsByWorkflow returns all workflow steps for a workflow. +func (r *Repository) ListStepsByWorkflow(ctx context.Context, workflowID string) ([]*models.WorkflowStep, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, board_id, name, step_type, position, color, - auto_start_agent, plan_mode, require_approval, - prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id, - allow_manual_move, created_at, updated_at - FROM workflow_steps WHERE board_id = ? ORDER BY position - `, boardID) + SELECT `+stepSelectColumns+` + FROM workflow_steps WHERE workflow_id = ? ORDER BY position + `, workflowID) if err != nil { return nil, err } @@ -718,89 +1047,14 @@ func (r *Repository) ListStepsByBoard(ctx context.Context, boardID string) ([]*m return r.scanSteps(rows) } -// GetStepByBoardAndType retrieves a workflow step by board ID and step type. -func (r *Repository) GetStepByBoardAndType(ctx context.Context, boardID string, stepType models.StepType) (*models.WorkflowStep, error) { - step := &models.WorkflowStep{} - var autoStartAgent, planMode, requireApproval, allowManualMove int - var onCompleteStepID, onApprovalStepID, promptPrefix, promptSuffix, color sql.NullString - - err := r.db.QueryRowContext(ctx, ` - SELECT id, board_id, name, step_type, position, color, - auto_start_agent, plan_mode, require_approval, - prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id, - allow_manual_move, created_at, updated_at - FROM workflow_steps WHERE board_id = ? AND step_type = ? - `, boardID, stepType).Scan(&step.ID, &step.BoardID, &step.Name, &step.StepType, &step.Position, &color, - &autoStartAgent, &planMode, &requireApproval, - &promptPrefix, &promptSuffix, &onCompleteStepID, &onApprovalStepID, - &allowManualMove, &step.CreatedAt, &step.UpdatedAt) - - if err == sql.ErrNoRows { - return nil, fmt.Errorf("workflow step not found for board %s with type %s", boardID, stepType) - } - if err != nil { - return nil, err - } - - step.AutoStartAgent = autoStartAgent == 1 - step.PlanMode = planMode == 1 - step.RequireApproval = requireApproval == 1 - step.AllowManualMove = allowManualMove == 1 - if color.Valid { - step.Color = color.String - } - if promptPrefix.Valid { - step.PromptPrefix = promptPrefix.String - } - if promptSuffix.Valid { - step.PromptSuffix = promptSuffix.String - } - if onCompleteStepID.Valid { - step.OnCompleteStepID = &onCompleteStepID.String - } - if onApprovalStepID.Valid { - step.OnApprovalStepID = &onApprovalStepID.String - } - - return step, nil -} - // scanSteps is a helper to scan multiple workflow step rows. func (r *Repository) scanSteps(rows *sql.Rows) ([]*models.WorkflowStep, error) { var result []*models.WorkflowStep for rows.Next() { - step := &models.WorkflowStep{} - var autoStartAgent, planMode, requireApproval, allowManualMove int - var onCompleteStepID, onApprovalStepID, promptPrefix, promptSuffix, color sql.NullString - - err := rows.Scan(&step.ID, &step.BoardID, &step.Name, &step.StepType, &step.Position, &color, - &autoStartAgent, &planMode, &requireApproval, - &promptPrefix, &promptSuffix, &onCompleteStepID, &onApprovalStepID, - &allowManualMove, &step.CreatedAt, &step.UpdatedAt) + step, err := r.scanStep(rows) if err != nil { return nil, err } - - step.AutoStartAgent = autoStartAgent == 1 - step.PlanMode = planMode == 1 - step.RequireApproval = requireApproval == 1 - step.AllowManualMove = allowManualMove == 1 - if color.Valid { - step.Color = color.String - } - if promptPrefix.Valid { - step.PromptPrefix = promptPrefix.String - } - if promptSuffix.Valid { - step.PromptSuffix = promptSuffix.String - } - if onCompleteStepID.Valid { - step.OnCompleteStepID = &onCompleteStepID.String - } - if onApprovalStepID.Valid { - step.OnApprovalStepID = &onApprovalStepID.String - } - result = append(result, step) } return result, rows.Err() diff --git a/apps/backend/internal/workflow/service/service.go b/apps/backend/internal/workflow/service/service.go index fc7bde1212..1c677f2978 100644 --- a/apps/backend/internal/workflow/service/service.go +++ b/apps/backend/internal/workflow/service/service.go @@ -65,11 +65,11 @@ func (s *Service) GetSystemTemplates(ctx context.Context) ([]*models.WorkflowTem // Step Operations // ============================================================================ -// ListStepsByBoard returns all workflow steps for a board. -func (s *Service) ListStepsByBoard(ctx context.Context, boardID string) ([]*models.WorkflowStep, error) { - steps, err := s.repo.ListStepsByBoard(ctx, boardID) +// ListStepsByWorkflow returns all workflow steps for a workflow. +func (s *Service) ListStepsByWorkflow(ctx context.Context, workflowID string) ([]*models.WorkflowStep, error) { + steps, err := s.repo.ListStepsByWorkflow(ctx, workflowID) if err != nil { - s.logger.Error("failed to list steps by board", zap.String("board_id", boardID), zap.Error(err)) + s.logger.Error("failed to list steps by workflow", zap.String("workflow_id", workflowID), zap.Error(err)) return nil, err } return steps, nil @@ -85,65 +85,19 @@ func (s *Service) GetStep(ctx context.Context, stepID string) (*models.WorkflowS return step, nil } -// GetNextStep gets the step to transition to based on OnCompleteStepID. -func (s *Service) GetNextStep(ctx context.Context, boardID, currentStepID string) (*models.WorkflowStep, error) { - currentStep, err := s.repo.GetStep(ctx, currentStepID) - if err != nil { - s.logger.Error("failed to get current step", zap.String("step_id", currentStepID), zap.Error(err)) - return nil, err - } - - if currentStep.OnCompleteStepID == nil { - return nil, nil // No next step configured - } - - nextStep, err := s.repo.GetStep(ctx, *currentStep.OnCompleteStepID) - if err != nil { - s.logger.Error("failed to get next step", - zap.String("current_step_id", currentStepID), - zap.String("next_step_id", *currentStep.OnCompleteStepID), - zap.Error(err)) - return nil, err - } - - return nextStep, nil -} - -// GetSourceStep finds the step that has on_complete_step_id pointing to the given step. -// This is used to find the "previous" step when moving back from a review step. -// Returns nil if no source step is found. -func (s *Service) GetSourceStep(ctx context.Context, boardID, targetStepID string) (*models.WorkflowStep, error) { - steps, err := s.repo.ListStepsByBoard(ctx, boardID) - if err != nil { - s.logger.Error("failed to list steps for source lookup", - zap.String("board_id", boardID), - zap.Error(err)) - return nil, err - } - - for _, step := range steps { - if step.OnCompleteStepID != nil && *step.OnCompleteStepID == targetStepID { - return step, nil - } - } - - return nil, nil // No source step found -} - -// GetNextStepByPosition returns the next step after the given position for a board. -// This is used as a fallback when no explicit transition (OnApprovalStepID, OnCompleteStepID) is configured. +// GetNextStepByPosition returns the next step after the given position for a workflow. // Steps are ordered by position, so this finds the step with the next higher position. // Returns nil if there is no next step (i.e., current step is the last one). -func (s *Service) GetNextStepByPosition(ctx context.Context, boardID string, currentPosition int) (*models.WorkflowStep, error) { - steps, err := s.repo.ListStepsByBoard(ctx, boardID) +func (s *Service) GetNextStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*models.WorkflowStep, error) { + steps, err := s.repo.ListStepsByWorkflow(ctx, workflowID) if err != nil { s.logger.Error("failed to list steps for next step lookup", - zap.String("board_id", boardID), + zap.String("workflow_id", workflowID), zap.Error(err)) return nil, err } - // Steps are already ordered by position from ListStepsByBoard + // Steps are already ordered by position from ListStepsByWorkflow for _, step := range steps { if step.Position > currentPosition { return step, nil @@ -153,8 +107,31 @@ func (s *Service) GetNextStepByPosition(ctx context.Context, boardID string, cur return nil, nil // No next step found (current step is the last one) } -// CreateStepsFromTemplate creates workflow steps for a board from a template. -func (s *Service) CreateStepsFromTemplate(ctx context.Context, boardID, templateID string) error { +// GetPreviousStepByPosition returns the previous step before the given position for a workflow. +// Returns nil if there is no previous step (i.e., current step is the first one). +func (s *Service) GetPreviousStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*models.WorkflowStep, error) { + steps, err := s.repo.ListStepsByWorkflow(ctx, workflowID) + if err != nil { + s.logger.Error("failed to list steps for previous step lookup", + zap.String("workflow_id", workflowID), + zap.Error(err)) + return nil, err + } + + // Steps are ordered by position ascending. Walk backwards to find the step just before current. + var prev *models.WorkflowStep + for _, step := range steps { + if step.Position >= currentPosition { + break + } + prev = step + } + + return prev, nil +} + +// CreateStepsFromTemplate creates workflow steps for a workflow from a template. +func (s *Service) CreateStepsFromTemplate(ctx context.Context, workflowID, templateID string) error { template, err := s.repo.GetTemplate(ctx, templateID) if err != nil { s.logger.Error("failed to get template for step creation", @@ -163,46 +140,30 @@ func (s *Service) CreateStepsFromTemplate(ctx context.Context, boardID, template return fmt.Errorf("failed to get template: %w", err) } - // Map template step IDs to generated UUIDs for linking - idMap := make(map[string]string) + // Build mapping from template step ID to new UUID + idMap := make(map[string]string, len(template.Steps)) for _, stepDef := range template.Steps { idMap[stepDef.ID] = uuid.New().String() } - // Create each step from the template + // Create each step from the template, remapping step_id references in events for _, stepDef := range template.Steps { + events := models.RemapStepEvents(stepDef.Events, idMap) step := &models.WorkflowStep{ ID: idMap[stepDef.ID], - BoardID: boardID, + WorkflowID: workflowID, Name: stepDef.Name, - StepType: stepDef.StepType, Position: stepDef.Position, Color: stepDef.Color, - AutoStartAgent: stepDef.AutoStartAgent, - PlanMode: stepDef.PlanMode, - RequireApproval: stepDef.RequireApproval, - PromptPrefix: stepDef.PromptPrefix, - PromptSuffix: stepDef.PromptSuffix, + Prompt: stepDef.Prompt, + Events: events, AllowManualMove: stepDef.AllowManualMove, - } - - // Map OnCompleteStepID if set - if stepDef.OnCompleteStepID != "" { - if mappedID, ok := idMap[stepDef.OnCompleteStepID]; ok { - step.OnCompleteStepID = &mappedID - } - } - - // Map OnApprovalStepID if set - if stepDef.OnApprovalStepID != "" { - if mappedID, ok := idMap[stepDef.OnApprovalStepID]; ok { - step.OnApprovalStepID = &mappedID - } + IsStartStep: stepDef.IsStartStep, } if err := s.repo.CreateStep(ctx, step); err != nil { s.logger.Error("failed to create step from template", - zap.String("board_id", boardID), + zap.String("workflow_id", workflowID), zap.String("step_name", step.Name), zap.Error(err)) return fmt.Errorf("failed to create step %s: %w", step.Name, err) @@ -210,26 +171,66 @@ func (s *Service) CreateStepsFromTemplate(ctx context.Context, boardID, template } s.logger.Info("created workflow steps from template", - zap.String("board_id", boardID), + zap.String("workflow_id", workflowID), zap.String("template_id", templateID), zap.Int("step_count", len(template.Steps))) return nil } +// ResolveStartStep resolves which step a task should start in for a workflow. +// Fallback chain: is_start_step=true → first step with auto_start_agent → first step by position. +func (s *Service) ResolveStartStep(ctx context.Context, workflowID string) (*models.WorkflowStep, error) { + // Try explicit start step + startStep, err := s.repo.GetStartStep(ctx, workflowID) + if err != nil { + s.logger.Error("failed to get start step", zap.String("workflow_id", workflowID), zap.Error(err)) + return nil, err + } + if startStep != nil { + return startStep, nil + } + + // Fallback: first step with auto_start_agent + steps, err := s.repo.ListStepsByWorkflow(ctx, workflowID) + if err != nil { + s.logger.Error("failed to list steps for start step resolution", zap.String("workflow_id", workflowID), zap.Error(err)) + return nil, err + } + if len(steps) == 0 { + return nil, fmt.Errorf("workflow %s has no steps", workflowID) + } + + for _, step := range steps { + if step.HasOnEnterAction(models.OnEnterAutoStartAgent) { + return step, nil + } + } + + // Fallback: first step by position + return steps[0], nil +} + // CreateStep creates a new workflow step. func (s *Service) CreateStep(ctx context.Context, step *models.WorkflowStep) error { step.ID = uuid.New().String() if err := s.repo.CreateStep(ctx, step); err != nil { - s.logger.Error("failed to create step", zap.String("board_id", step.BoardID), zap.Error(err)) + s.logger.Error("failed to create step", zap.String("workflow_id", step.WorkflowID), zap.Error(err)) return err } - s.logger.Info("created workflow step", zap.String("step_id", step.ID), zap.String("board_id", step.BoardID)) + s.logger.Info("created workflow step", zap.String("step_id", step.ID), zap.String("workflow_id", step.WorkflowID)) return nil } // UpdateStep updates an existing workflow step. func (s *Service) UpdateStep(ctx context.Context, step *models.WorkflowStep) error { + // If marking as start step, clear the flag on all other steps first + if step.IsStartStep { + if err := s.repo.ClearStartStepFlag(ctx, step.WorkflowID, step.ID); err != nil { + s.logger.Error("failed to clear start step flag", zap.String("workflow_id", step.WorkflowID), zap.Error(err)) + return err + } + } if err := s.repo.UpdateStep(ctx, step); err != nil { s.logger.Error("failed to update step", zap.String("step_id", step.ID), zap.Error(err)) return err @@ -240,18 +241,18 @@ func (s *Service) UpdateStep(ctx context.Context, step *models.WorkflowStep) err // DeleteStep deletes a workflow step and clears any references to it from other steps. func (s *Service) DeleteStep(ctx context.Context, stepID string) error { - // First, get the step to find its board ID + // First, get the step to find its workflow ID step, err := s.repo.GetStep(ctx, stepID) if err != nil { s.logger.Error("failed to get step for deletion", zap.String("step_id", stepID), zap.Error(err)) return err } - // Clear any OnCompleteStepID or OnApprovalStepID references to this step - if err := s.repo.ClearStepReferences(ctx, step.BoardID, stepID); err != nil { + // Clear any move_to_step references to this step + if err := s.repo.ClearStepReferences(ctx, step.WorkflowID, stepID); err != nil { s.logger.Error("failed to clear step references", zap.String("step_id", stepID), - zap.String("board_id", step.BoardID), + zap.String("workflow_id", step.WorkflowID), zap.Error(err)) return err } @@ -264,12 +265,12 @@ func (s *Service) DeleteStep(ctx context.Context, stepID string) error { s.logger.Info("deleted workflow step and cleared references", zap.String("step_id", stepID), - zap.String("board_id", step.BoardID)) + zap.String("workflow_id", step.WorkflowID)) return nil } -// ReorderSteps reorders workflow steps for a board. -func (s *Service) ReorderSteps(ctx context.Context, boardID string, stepIDs []string) error { +// ReorderSteps reorders workflow steps for a workflow. +func (s *Service) ReorderSteps(ctx context.Context, workflowID string, stepIDs []string) error { for i, stepID := range stepIDs { step, err := s.repo.GetStep(ctx, stepID) if err != nil { @@ -282,7 +283,7 @@ func (s *Service) ReorderSteps(ctx context.Context, boardID string, stepIDs []st return err } } - s.logger.Info("reordered workflow steps", zap.String("board_id", boardID), zap.Int("count", len(stepIDs))) + s.logger.Info("reordered workflow steps", zap.String("workflow_id", workflowID), zap.Int("count", len(stepIDs))) return nil } diff --git a/apps/backend/internal/workflow/service/service_test.go b/apps/backend/internal/workflow/service/service_test.go new file mode 100644 index 0000000000..bd14971867 --- /dev/null +++ b/apps/backend/internal/workflow/service/service_test.go @@ -0,0 +1,308 @@ +package service + +import ( + "context" + "database/sql" + "testing" + + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/workflow/models" + "github.com/kandev/kandev/internal/workflow/repository" +) + +func setupTestService(t *testing.T) (*Service, *sql.DB) { + db, err := sql.Open("sqlite3", ":memory:") + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + // Create workflows table (normally created by task repo) + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS workflows ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL DEFAULT '', + workflow_template_id TEXT DEFAULT '', name TEXT NOT NULL, + description TEXT DEFAULT '', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL + )`) + require.NoError(t, err) + + repo, err := repository.NewWithDB(db) + require.NoError(t, err) + + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"}) + return NewService(repo, log), db +} + +func insertWorkflow(t *testing.T, db *sql.DB, id, name string) { + _, err := db.Exec("INSERT INTO workflows (id, workspace_id, name, created_at, updated_at) VALUES (?, '', ?, datetime('now'), datetime('now'))", id, name) + require.NoError(t, err) +} + +func createStep(t *testing.T, svc *Service, step *models.WorkflowStep) { + err := svc.CreateStep(context.Background(), step) + require.NoError(t, err) +} + +func TestGetNextStepByPosition(t *testing.T) { + t.Run("middle step returns next step", func(t *testing.T) { + svc, db := setupTestService(t) + ctx := context.Background() + + insertWorkflow(t, db, "wf-1", "Test Workflow") + + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Todo", Position: 0}) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "In Progress", Position: 1}) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Done", Position: 2}) + + next, err := svc.GetNextStepByPosition(ctx, "wf-1", 0) + require.NoError(t, err) + assert.NotNil(t, next) + assert.Equal(t, "In Progress", next.Name) + assert.Equal(t, 1, next.Position) + }) + + t.Run("last step returns nil", func(t *testing.T) { + svc, db := setupTestService(t) + ctx := context.Background() + + insertWorkflow(t, db, "wf-1", "Test Workflow") + + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Todo", Position: 0}) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "In Progress", Position: 1}) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Done", Position: 2}) + + next, err := svc.GetNextStepByPosition(ctx, "wf-1", 2) + require.NoError(t, err) + assert.Nil(t, next) + }) + + t.Run("gaps in positions still finds next", func(t *testing.T) { + svc, db := setupTestService(t) + ctx := context.Background() + + insertWorkflow(t, db, "wf-1", "Test Workflow") + + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "First", Position: 0}) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Second", Position: 2}) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Third", Position: 5}) + + next, err := svc.GetNextStepByPosition(ctx, "wf-1", 0) + require.NoError(t, err) + assert.NotNil(t, next) + assert.Equal(t, "Second", next.Name) + assert.Equal(t, 2, next.Position) + + next, err = svc.GetNextStepByPosition(ctx, "wf-1", 2) + require.NoError(t, err) + assert.NotNil(t, next) + assert.Equal(t, "Third", next.Name) + assert.Equal(t, 5, next.Position) + }) + + t.Run("single step returns nil", func(t *testing.T) { + svc, db := setupTestService(t) + ctx := context.Background() + + insertWorkflow(t, db, "wf-1", "Test Workflow") + + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Only Step", Position: 0}) + + next, err := svc.GetNextStepByPosition(ctx, "wf-1", 0) + require.NoError(t, err) + assert.Nil(t, next) + }) +} + +func TestGetPreviousStepByPosition(t *testing.T) { + t.Run("middle step returns previous step", func(t *testing.T) { + svc, db := setupTestService(t) + ctx := context.Background() + + insertWorkflow(t, db, "wf-1", "Test Workflow") + + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Todo", Position: 0}) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "In Progress", Position: 1}) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Done", Position: 2}) + + prev, err := svc.GetPreviousStepByPosition(ctx, "wf-1", 2) + require.NoError(t, err) + assert.NotNil(t, prev) + assert.Equal(t, "In Progress", prev.Name) + assert.Equal(t, 1, prev.Position) + }) + + t.Run("first step returns nil", func(t *testing.T) { + svc, db := setupTestService(t) + ctx := context.Background() + + insertWorkflow(t, db, "wf-1", "Test Workflow") + + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Todo", Position: 0}) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "In Progress", Position: 1}) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Done", Position: 2}) + + prev, err := svc.GetPreviousStepByPosition(ctx, "wf-1", 0) + require.NoError(t, err) + assert.Nil(t, prev) + }) + + t.Run("gaps in positions still finds previous", func(t *testing.T) { + svc, db := setupTestService(t) + ctx := context.Background() + + insertWorkflow(t, db, "wf-1", "Test Workflow") + + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "First", Position: 0}) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Second", Position: 2}) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Third", Position: 5}) + + prev, err := svc.GetPreviousStepByPosition(ctx, "wf-1", 5) + require.NoError(t, err) + assert.NotNil(t, prev) + assert.Equal(t, "Second", prev.Name) + assert.Equal(t, 2, prev.Position) + + prev, err = svc.GetPreviousStepByPosition(ctx, "wf-1", 2) + require.NoError(t, err) + assert.NotNil(t, prev) + assert.Equal(t, "First", prev.Name) + assert.Equal(t, 0, prev.Position) + }) + + t.Run("single step returns nil", func(t *testing.T) { + svc, db := setupTestService(t) + ctx := context.Background() + + insertWorkflow(t, db, "wf-1", "Test Workflow") + + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Only Step", Position: 0}) + + prev, err := svc.GetPreviousStepByPosition(ctx, "wf-1", 0) + require.NoError(t, err) + assert.Nil(t, prev) + }) +} + +func TestResolveStartStep(t *testing.T) { + t.Run("explicit is_start_step returns that step", func(t *testing.T) { + svc, db := setupTestService(t) + ctx := context.Background() + + insertWorkflow(t, db, "wf-1", "Test Workflow") + + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Todo", Position: 0}) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Start Here", Position: 1, IsStartStep: true}) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Done", Position: 2}) + + start, err := svc.ResolveStartStep(ctx, "wf-1") + require.NoError(t, err) + assert.NotNil(t, start) + assert.Equal(t, "Start Here", start.Name) + assert.True(t, start.IsStartStep) + }) + + t.Run("fallback to first step with auto_start_agent", func(t *testing.T) { + svc, db := setupTestService(t) + ctx := context.Background() + + insertWorkflow(t, db, "wf-1", "Test Workflow") + + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Todo", Position: 0}) + createStep(t, svc, &models.WorkflowStep{ + WorkflowID: "wf-1", + Name: "Auto Start", + Position: 1, + Events: models.StepEvents{ + OnEnter: []models.OnEnterAction{ + {Type: models.OnEnterAutoStartAgent}, + }, + }, + }) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Done", Position: 2}) + + start, err := svc.ResolveStartStep(ctx, "wf-1") + require.NoError(t, err) + assert.NotNil(t, start) + assert.Equal(t, "Auto Start", start.Name) + }) + + t.Run("fallback to first step by position", func(t *testing.T) { + svc, db := setupTestService(t) + ctx := context.Background() + + insertWorkflow(t, db, "wf-1", "Test Workflow") + + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "First", Position: 0}) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Second", Position: 1}) + createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Third", Position: 2}) + + start, err := svc.ResolveStartStep(ctx, "wf-1") + require.NoError(t, err) + assert.NotNil(t, start) + assert.Equal(t, "First", start.Name) + assert.Equal(t, 0, start.Position) + }) + + t.Run("empty workflow returns error", func(t *testing.T) { + svc, db := setupTestService(t) + ctx := context.Background() + + insertWorkflow(t, db, "wf-empty", "Empty Workflow") + + start, err := svc.ResolveStartStep(ctx, "wf-empty") + assert.Error(t, err) + assert.Nil(t, start) + assert.Contains(t, err.Error(), "has no steps") + }) +} + +func TestCreateStepsFromTemplate_RemapsStepIDs(t *testing.T) { + svc, db := setupTestService(t) + ctx := context.Background() + + insertWorkflow(t, db, "wf-1", "Test Workflow") + + // Use the "simple" (Kanban) template which has move_to_step references + err := svc.CreateStepsFromTemplate(ctx, "wf-1", "simple") + require.NoError(t, err) + + steps, err := svc.repo.ListStepsByWorkflow(ctx, "wf-1") + require.NoError(t, err) + require.Len(t, steps, 4) + + // Build a map of step name → ID for verification + nameToID := make(map[string]string, len(steps)) + for _, s := range steps { + nameToID[s.Name] = s.ID + } + + // Backlog's OnTurnComplete should reference the real Review step UUID + backlog := findStepByName(steps, "Backlog") + require.NotNil(t, backlog) + require.Len(t, backlog.Events.OnTurnComplete, 1) + assert.Equal(t, models.OnTurnCompleteMoveToStep, backlog.Events.OnTurnComplete[0].Type) + assert.Equal(t, nameToID["Review"], backlog.Events.OnTurnComplete[0].Config["step_id"]) + + // In Progress's OnTurnComplete should reference the real Review step UUID + inProgress := findStepByName(steps, "In Progress") + require.NotNil(t, inProgress) + require.Len(t, inProgress.Events.OnTurnComplete, 1) + assert.Equal(t, nameToID["Review"], inProgress.Events.OnTurnComplete[0].Config["step_id"]) + + // Done's OnTurnStart should reference the real In Progress step UUID + done := findStepByName(steps, "Done") + require.NotNil(t, done) + require.Len(t, done.Events.OnTurnStart, 1) + assert.Equal(t, models.OnTurnStartMoveToStep, done.Events.OnTurnStart[0].Type) + assert.Equal(t, nameToID["In Progress"], done.Events.OnTurnStart[0].Config["step_id"]) +} + +func findStepByName(steps []*models.WorkflowStep, name string) *models.WorkflowStep { + for _, s := range steps { + if s.Name == name { + return s + } + } + return nil +} diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go index 46d776468d..31e3cd1245 100644 --- a/apps/backend/pkg/api/v1/task.go +++ b/apps/backend/pkg/api/v1/task.go @@ -62,7 +62,7 @@ type TaskRepository struct { type Task struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` + WorkflowID string `json:"workflow_id"` Title string `json:"title"` Description string `json:"description"` State TaskState `json:"state"` diff --git a/apps/backend/pkg/api/v1/user.go b/apps/backend/pkg/api/v1/user.go index 5b9caa9774..ba09740e81 100644 --- a/apps/backend/pkg/api/v1/user.go +++ b/apps/backend/pkg/api/v1/user.go @@ -11,10 +11,11 @@ type User struct { } type UserSettings struct { - UserID string `json:"user_id"` - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - RepositoryIDs []string `json:"repository_ids"` + UserID string `json:"user_id"` + WorkspaceID string `json:"workspace_id"` + KanbanViewMode string `json:"kanban_view_mode"` + WorkflowFilterID string `json:"workflow_filter_id"` + RepositoryIDs []string `json:"repository_ids"` PreferredShell string `json:"preferred_shell"` DefaultEditorID string `json:"default_editor_id"` UpdatedAt time.Time `json:"updated_at"` diff --git a/apps/backend/pkg/api/v1/board.go b/apps/backend/pkg/api/v1/workflow.go similarity index 69% rename from apps/backend/pkg/api/v1/board.go rename to apps/backend/pkg/api/v1/workflow.go index b992ca647b..4159a94d9f 100644 --- a/apps/backend/pkg/api/v1/board.go +++ b/apps/backend/pkg/api/v1/workflow.go @@ -2,8 +2,8 @@ package v1 import "time" -// Board represents a Kanban board -type Board struct { +// Workflow represents a task workflow +type Workflow struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` Name string `json:"name"` @@ -12,14 +12,14 @@ type Board struct { UpdatedAt time.Time `json:"updated_at"` } -// CreateBoardRequest for creating a new board -type CreateBoardRequest struct { +// CreateWorkflowRequest for creating a new workflow +type CreateWorkflowRequest struct { Name string `json:"name" binding:"required,max=255"` Description *string `json:"description,omitempty"` } -// UpdateBoardRequest for updating a board -type UpdateBoardRequest struct { +// UpdateWorkflowRequest for updating a workflow +type UpdateWorkflowRequest struct { Name *string `json:"name,omitempty" binding:"omitempty,max=255"` Description *string `json:"description,omitempty"` } diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index ee60a6c204..86c759a96f 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -5,12 +5,12 @@ const ( // Health ActionHealthCheck = "health.check" - // Board actions - ActionBoardList = "board.list" - ActionBoardCreate = "board.create" - ActionBoardGet = "board.get" - ActionBoardUpdate = "board.update" - ActionBoardDelete = "board.delete" + // Workflow actions + ActionWorkflowList = "workflow.list" + ActionWorkflowCreate = "workflow.create" + ActionWorkflowGet = "workflow.get" + ActionWorkflowUpdate = "workflow.update" + ActionWorkflowDelete = "workflow.delete" // Workspace actions ActionWorkspaceList = "workspace.list" @@ -98,7 +98,7 @@ const ( ActionMessageQueueUpdate = "message.queue.update" ActionMessageQueueStatusChanged = "message.queue.status_changed" // Notification: queue status changed - // Workflow actions + // Workflow template/step actions ActionWorkflowTemplateList = "workflow.template.list" ActionWorkflowTemplateGet = "workflow.template.get" ActionWorkflowStepList = "workflow.step.list" @@ -138,9 +138,9 @@ const ( ActionWorkspaceCreated = "workspace.created" ActionWorkspaceUpdated = "workspace.updated" ActionWorkspaceDeleted = "workspace.deleted" - ActionBoardCreated = "board.created" - ActionBoardUpdated = "board.updated" - ActionBoardDeleted = "board.deleted" + ActionWorkflowCreated = "workflow.created" + ActionWorkflowUpdated = "workflow.updated" + ActionWorkflowDeleted = "workflow.deleted" ActionSessionMessageAdded = "session.message.added" ActionSessionMessageUpdated = "session.message.updated" ActionSessionStateChanged = "session.state_changed" @@ -229,7 +229,7 @@ const ( // MCP tool actions (agentctl -> backend via WS tunnel) ActionMCPListWorkspaces = "mcp.list_workspaces" - ActionMCPListBoards = "mcp.list_boards" + ActionMCPListWorkflows = "mcp.list_workflows" ActionMCPListWorkflowSteps = "mcp.list_workflow_steps" ActionMCPListTasks = "mcp.list_tasks" ActionMCPCreateTask = "mcp.create_task" diff --git a/apps/packages/ui/src/dialog.tsx b/apps/packages/ui/src/dialog.tsx index 19fd9a4735..e46dcd5856 100644 --- a/apps/packages/ui/src/dialog.tsx +++ b/apps/packages/ui/src/dialog.tsx @@ -60,7 +60,7 @@ function DialogContent({ ( function SessionPanelContent({ children, className, wrapperClassName }, ref) { + const internalRef = useRef(null); + /** + * Continuously updated by the scroll listener so we always have + * the latest good scrollTop before the browser resets it on display:none. + */ + const savedScrollTopRef = useRef(0); + const isHiddenRef = useRef(false); + + // Merge forwarded ref with internal ref + const setRefs = useCallback( + (node: HTMLDivElement | null) => { + internalRef.current = node; + if (typeof ref === 'function') ref(node); + else if (ref) ref.current = node; + }, + [ref] + ); + + // Save scrollTop on every scroll event so it's always up-to-date + useEffect(() => { + const el = internalRef.current; + if (!el) return; + + const onScroll = () => { + if (!isHiddenRef.current) { + savedScrollTopRef.current = el.scrollTop; + } + }; + + el.addEventListener('scroll', onScroll); + return () => el.removeEventListener('scroll', onScroll); + }, []); + + // Detect hide/show via ResizeObserver and restore scroll position + useEffect(() => { + const el = internalRef.current; + if (!el) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + + if (width === 0 || height === 0) { + isHiddenRef.current = true; + return; + } + + if (isHiddenRef.current) { + isHiddenRef.current = false; + const saved = savedScrollTopRef.current; + requestAnimationFrame(() => { + el.scrollTop = saved; + }); + } + } + }); + + observer.observe(el); + return () => observer.disconnect(); + }, []); + return (
(`${apiBaseUrl}/api/v1/workspaces/${id}`, { method: 'DELETE' }); } -export async function listBoardsAction(workspaceId: string): Promise { - const url = new URL(`${apiBaseUrl}/api/v1/boards`); +export async function listWorkflowsAction(workspaceId: string): Promise { + const url = new URL(`${apiBaseUrl}/api/v1/workflows`); url.searchParams.set('workspace_id', workspaceId); - return fetchJson(url.toString()); + return fetchJson(url.toString()); } export async function listTasksByWorkspaceAction( @@ -108,27 +107,27 @@ export async function listTasksByWorkspaceAction( return fetchJson(url.toString()); } -export async function createBoardAction(payload: { +export async function createWorkflowAction(payload: { workspace_id: string; name: string; description?: string; workflow_template_id?: string; }) { - return fetchJson(`${apiBaseUrl}/api/v1/boards`, { + return fetchJson(`${apiBaseUrl}/api/v1/workflows`, { method: 'POST', body: JSON.stringify(payload), }); } -export async function updateBoardAction(id: string, payload: { name?: string; description?: string }) { - return fetchJson(`${apiBaseUrl}/api/v1/boards/${id}`, { +export async function updateWorkflowAction(id: string, payload: { name?: string; description?: string }) { + return fetchJson(`${apiBaseUrl}/api/v1/workflows/${id}`, { method: 'PATCH', body: JSON.stringify(payload), }); } -export async function deleteBoardAction(id: string) { - await fetchJson(`${apiBaseUrl}/api/v1/boards/${id}`, { method: 'DELETE' }); +export async function deleteWorkflowAction(id: string) { + await fetchJson(`${apiBaseUrl}/api/v1/workflows/${id}`, { method: 'DELETE' }); } export async function listRepositoriesAction(workspaceId: string): Promise { @@ -243,51 +242,27 @@ export async function deleteRepositoryScriptAction(id: string) { await fetchJson(`${apiBaseUrl}/api/v1/scripts/${id}`, { method: 'DELETE' }); } -type BackendWorkflowTemplateStep = { +type BackendTemplateStep = { name: string; - step_type: WorkflowStepType; position: number; color?: string; - auto_start_agent?: boolean; - plan_mode?: boolean; - require_approval?: boolean; - prompt_prefix?: string; - prompt_suffix?: string; + prompt?: string; + events?: StepEvents; }; type BackendWorkflowTemplate = Omit & { - steps?: BackendWorkflowTemplateStep[]; - default_steps?: BackendWorkflowTemplateStep[]; -}; - -const toStepBehaviors = (step: BackendWorkflowTemplateStep): StepBehaviors | undefined => { - const behaviors: StepBehaviors = {}; - if (step.auto_start_agent !== undefined) { - behaviors.autoStartAgent = step.auto_start_agent; - } - if (step.plan_mode !== undefined) { - behaviors.planMode = step.plan_mode; - } - if (step.require_approval !== undefined) { - behaviors.requireApproval = step.require_approval; - } - if (step.prompt_prefix) { - behaviors.promptPrefix = step.prompt_prefix; - } - if (step.prompt_suffix) { - behaviors.promptSuffix = step.prompt_suffix; - } - return Object.keys(behaviors).length ? behaviors : undefined; + steps?: BackendTemplateStep[]; + default_steps?: BackendTemplateStep[]; }; const normalizeWorkflowTemplate = (template: BackendWorkflowTemplate): WorkflowTemplate => { const steps = template.default_steps ?? template.steps ?? []; const default_steps: StepDefinition[] = steps.map((step) => ({ name: step.name, - step_type: step.step_type, position: step.position, color: step.color, - behaviors: toStepBehaviors(step), + prompt: step.prompt, + events: step.events, })); return { ...template, @@ -308,51 +283,38 @@ export async function listWorkflowTemplatesAction(): Promise ({ + id: step.id, + workflow_id: step.workflow_id, + name: step.name, + position: step.position, + color: step.color, + prompt: step.prompt, + events: step.events, + allow_manual_move: step.allow_manual_move, + is_start_step: step.is_start_step, + created_at: step.created_at, + updated_at: step.updated_at, +}); // Workflow Steps -export async function listWorkflowStepsAction(boardId: string): Promise { +export async function listWorkflowStepsAction(workflowId: string): Promise { const response = await fetchJson<{ steps: BackendWorkflowStep[] | null }>( - `${apiBaseUrl}/api/v1/boards/${boardId}/workflow/steps` + `${apiBaseUrl}/api/v1/workflows/${workflowId}/workflow/steps` ); return { steps: (response.steps ?? []).map(transformWorkflowStep), @@ -361,26 +323,24 @@ export async function listWorkflowStepsAction(boardId: string): Promise { - // Flatten behaviors into top-level fields for the backend const body = { - board_id: payload.board_id, + workflow_id: payload.workflow_id, name: payload.name, - step_type: payload.step_type, position: payload.position, color: payload.color, - auto_start_agent: payload.behaviors?.autoStartAgent ?? false, - plan_mode: payload.behaviors?.planMode ?? false, - require_approval: payload.behaviors?.requireApproval ?? false, - prompt_prefix: payload.behaviors?.promptPrefix ?? '', - prompt_suffix: payload.behaviors?.promptSuffix ?? '', - allow_manual_move: true, + prompt: payload.prompt ?? '', + events: payload.events, + allow_manual_move: payload.allow_manual_move ?? true, + is_start_step: payload.is_start_step ?? false, }; const response = await fetchJson(`${apiBaseUrl}/api/v1/workflow/steps`, { method: 'POST', @@ -391,21 +351,16 @@ export async function createWorkflowStepAction(payload: { export async function updateWorkflowStepAction( stepId: string, - payload: Partial> + payload: Partial> ): Promise { - // Flatten behaviors into top-level fields for the backend const body: Record = {}; if (payload.name !== undefined) body.name = payload.name; - if (payload.step_type !== undefined) body.step_type = payload.step_type; if (payload.position !== undefined) body.position = payload.position; if (payload.color !== undefined) body.color = payload.color; - if (payload.behaviors !== undefined) { - if (payload.behaviors.autoStartAgent !== undefined) body.auto_start_agent = payload.behaviors.autoStartAgent; - if (payload.behaviors.planMode !== undefined) body.plan_mode = payload.behaviors.planMode; - if (payload.behaviors.requireApproval !== undefined) body.require_approval = payload.behaviors.requireApproval; - if (payload.behaviors.promptPrefix !== undefined) body.prompt_prefix = payload.behaviors.promptPrefix; - if (payload.behaviors.promptSuffix !== undefined) body.prompt_suffix = payload.behaviors.promptSuffix; - } + if (payload.prompt !== undefined) body.prompt = payload.prompt; + if (payload.events !== undefined) body.events = payload.events; + if (payload.allow_manual_move !== undefined) body.allow_manual_move = payload.allow_manual_move; + if (payload.is_start_step !== undefined) body.is_start_step = payload.is_start_step; const response = await fetchJson(`${apiBaseUrl}/api/v1/workflow/steps/${stepId}`, { method: 'PUT', body: JSON.stringify(body), @@ -417,9 +372,9 @@ export async function deleteWorkflowStepAction(stepId: string) { await fetchJson(`${apiBaseUrl}/api/v1/workflow/steps/${stepId}`, { method: 'DELETE' }); } -export async function reorderWorkflowStepsAction(boardId: string, stepIds: string[]): Promise { +export async function reorderWorkflowStepsAction(workflowId: string, stepIds: string[]): Promise { const response = await fetchJson<{ steps: BackendWorkflowStep[] | null }>( - `${apiBaseUrl}/api/v1/boards/${boardId}/workflow/steps/reorder`, + `${apiBaseUrl}/api/v1/workflows/${workflowId}/workflow/steps/reorder`, { method: 'PUT', body: JSON.stringify({ step_ids: stepIds }), @@ -447,6 +402,26 @@ export async function approveSessionAction(sessionId: string): Promise { + return fetchJson<{ task_count: number }>(`${apiBaseUrl}/api/v1/workflows/${workflowId}/task-count`); +} + +export async function getStepTaskCount(stepId: string): Promise<{ task_count: number }> { + return fetchJson<{ task_count: number }>(`${apiBaseUrl}/api/v1/workflow/steps/${stepId}/task-count`); +} + +export async function bulkMoveTasks(payload: { + source_workflow_id: string; + source_step_id?: string; + target_workflow_id: string; + target_step_id: string; +}): Promise<{ moved_count: number }> { + return fetchJson<{ moved_count: number }>(`${apiBaseUrl}/api/v1/tasks/bulk-move`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + export async function moveSessionToStepAction(sessionId: string, stepId: string) { return fetchJson(`${apiBaseUrl}/api/v1/sessions/${sessionId}/workflow-step`, { method: 'PUT', diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 83f51ac4ea..c9579d1788 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -985,6 +985,47 @@ border-radius: 8px; } +/* Pipeline node rotating border for running tasks */ +@property --border-angle { + syntax: ""; + initial-value: 0deg; + inherits: false; +} + +@keyframes node-border-rotate { + to { + --border-angle: 360deg; + } +} + +.node-border-running { + position: relative; +} + +.node-border-running::after { + content: ''; + position: absolute; + inset: -1px; + border-radius: inherit; + padding: 1.5px; + background: conic-gradient( + from var(--border-angle), + hsl(var(--muted-foreground) / 0.4) 0%, + transparent 12%, + transparent 38%, + hsl(var(--muted-foreground) / 0.4) 50%, + transparent 62%, + transparent 88%, + hsl(var(--muted-foreground) / 0.4) 100% + ); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + animation: node-border-rotate 3s linear infinite; + pointer-events: none; +} + .debug { border: 1px solid red !important; } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 2ba4f4e5a7..f855a29d4a 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -8,7 +8,7 @@ import { TooltipProvider } from "@kandev/ui/tooltip"; export const metadata: Metadata = { title: "Kandev - AI Kanban", - description: "AI-powered kanban board for developers", + description: "AI-powered workflow management for developers", }; export const viewport: Viewport = { diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 2d5db20286..484679da2f 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,6 +1,6 @@ import { PageClient } from '@/app/page-client'; import { StateHydrator } from '@/components/state-hydrator'; -import { fetchBoardSnapshot, fetchUserSettings, listBoards, listRepositories, listWorkspaces, listTaskSessionMessages } from '@/lib/api'; +import { fetchWorkflowSnapshot, fetchUserSettings, listWorkflows, listRepositories, listWorkspaces, listTaskSessionMessages } from '@/lib/api'; import { snapshotToState } from '@/lib/ssr/mapper'; import type { AppState } from '@/lib/state/store'; @@ -13,11 +13,11 @@ export default async function Page({ searchParams }: PageProps) { try { const resolvedParams = searchParams ? await searchParams : {}; const workspaceParam = resolvedParams.workspaceId; - const boardParam = resolvedParams.boardId; + const workflowParam = resolvedParams.workflowId; const taskIdParam = resolvedParams.taskId; const sessionIdParam = resolvedParams.sessionId; const workspaceId = Array.isArray(workspaceParam) ? workspaceParam[0] : workspaceParam; - const boardIdParam = Array.isArray(boardParam) ? boardParam[0] : boardParam; + const workflowIdParam = Array.isArray(workflowParam) ? workflowParam[0] : workflowParam; const taskId = Array.isArray(taskIdParam) ? taskIdParam[0] : taskIdParam; const sessionId = Array.isArray(sessionIdParam) ? sessionIdParam[0] : sessionIdParam; @@ -27,7 +27,7 @@ export default async function Page({ searchParams }: PageProps) { ]); const userSettings = userSettingsResponse?.settings; const settingsWorkspaceId = userSettings?.workspace_id || null; - const settingsBoardId = userSettings?.board_id || null; + const settingsWorkflowId = userSettings?.workflow_filter_id || null; const settingsRepositoryIds = Array.from(new Set(userSettings?.repository_ids ?? [])).sort(); const settingsEnablePreviewOnClick = userSettings?.enable_preview_on_click ?? false; const activeWorkspaceId = @@ -52,7 +52,8 @@ export default async function Page({ searchParams }: PageProps) { }, userSettings: { workspaceId: activeWorkspaceId, - boardId: settingsBoardId, + workflowId: settingsWorkflowId, + kanbanViewMode: userSettings?.kanban_view_mode || null, repositoryIds: settingsRepositoryIds, preferredShell: userSettings?.preferred_shell || null, shellOptions: userSettingsResponse?.shell_options ?? [], @@ -76,30 +77,30 @@ export default async function Page({ searchParams }: PageProps) { ); } - const [boardList, repositoriesResponse] = await Promise.all([ - listBoards(activeWorkspaceId, { cache: 'no-store' }), + const [workflowList, repositoriesResponse] = await Promise.all([ + listWorkflows(activeWorkspaceId, { cache: 'no-store' }), listRepositories(activeWorkspaceId, undefined, { cache: 'no-store' }).catch(() => ({ repositories: [] })), ]); - // Use boardIdParam if it exists in this workspace, otherwise fall back - // Client-side code will handle task selection even if board doesn't match - const boardId = - boardList.boards.find((board) => board.id === boardIdParam)?.id ?? - boardList.boards.find((board) => board.id === settingsBoardId)?.id ?? - boardList.boards[0]?.id; + // Use workflowIdParam if it exists in this workspace, otherwise fall back + // Client-side code will handle task selection even if workflow doesn't match + const workflowId = + workflowList.workflows.find((workflow) => workflow.id === workflowIdParam)?.id ?? + workflowList.workflows.find((workflow) => workflow.id === settingsWorkflowId)?.id ?? + workflowList.workflows[0]?.id; initialState = { ...initialState, userSettings: { ...(initialState.userSettings as AppState['userSettings']), - boardId: boardId ?? null, + workflowId: workflowId ?? null, }, - boards: { - items: boardList.boards.map((board) => ({ - id: board.id, - workspaceId: board.workspace_id, - name: board.name, + workflows: { + items: workflowList.workflows.map((workflow) => ({ + id: workflow.id, + workspaceId: workflow.workspace_id, + name: workflow.name, })), - activeId: boardId ?? null, + activeId: workflowId ?? null, }, repositories: { itemsByWorkspaceId: { [activeWorkspaceId]: repositoriesResponse.repositories }, @@ -108,7 +109,7 @@ export default async function Page({ searchParams }: PageProps) { }, }; - if (!boardId) { + if (!workflowId) { return ( <> @@ -117,7 +118,7 @@ export default async function Page({ searchParams }: PageProps) { ); } - const snapshot = await fetchBoardSnapshot(boardId, { cache: 'no-store' }); + const snapshot = await fetchWorkflowSnapshot(workflowId, { cache: 'no-store' }); initialState = { ...initialState, ...snapshotToState(snapshot) }; // Load messages for selected task session if provided (SSR optimization) diff --git a/apps/web/app/s/[sessionId]/page.tsx b/apps/web/app/s/[sessionId]/page.tsx index d859dab2c4..30bbd9576a 100644 --- a/apps/web/app/s/[sessionId]/page.tsx +++ b/apps/web/app/s/[sessionId]/page.tsx @@ -1,13 +1,13 @@ import { StateHydrator } from '@/components/state-hydrator'; import { readLayoutDefaults } from '@/lib/layout/read-layout-defaults'; import { - fetchBoardSnapshot, + fetchWorkflowSnapshot, fetchTaskSession, fetchTask, fetchUserSettings, listAgents, listAvailableAgents, - listBoards, + listWorkflows, listRepositories, listTaskSessionMessages, listTaskSessions, @@ -42,14 +42,14 @@ export default async function SessionPage({ } task = await fetchTask(session.task_id, { cache: 'no-store' }); - const [snapshot, agents, repositoriesResponse, allSessionsResponse, availableAgentsResponse, workspacesResponse, boardsResponse, turnsResponse, userSettingsResponse, terminalsResponse] = await Promise.all([ - fetchBoardSnapshot(task.board_id, { cache: 'no-store' }), + const [snapshot, agents, repositoriesResponse, allSessionsResponse, availableAgentsResponse, workspacesResponse, workflowsResponse, turnsResponse, userSettingsResponse, terminalsResponse] = await Promise.all([ + fetchWorkflowSnapshot(task.workflow_id, { cache: 'no-store' }), listAgents({ cache: 'no-store' }), listRepositories(task.workspace_id, { includeScripts: true }, { cache: 'no-store' }), listTaskSessions(session.task_id, { cache: 'no-store' }), listAvailableAgents({ cache: 'no-store' }).catch(() => ({ agents: [] })), listWorkspaces({ cache: 'no-store' }).catch(() => ({ workspaces: [] })), - listBoards(task.workspace_id, { cache: 'no-store' }).catch(() => ({ boards: [] })), + listWorkflows(task.workspace_id, { cache: 'no-store' }).catch(() => ({ workflows: [] })), listSessionTurns(sessionId, { cache: 'no-store' }).catch(() => ({ turns: [], total: 0 })), fetchUserSettings({ cache: 'no-store' }).catch(() => null), fetchTerminals(sessionId).catch(() => []), @@ -58,7 +58,7 @@ export default async function SessionPage({ const allSessions = allSessionsResponse.sessions ?? [session]; const availableAgents = availableAgentsResponse.agents ?? []; const workspaces = workspacesResponse.workspaces ?? []; - const boards = boardsResponse.boards ?? []; + const workflows = workflowsResponse.workflows ?? []; const turns = turnsResponse.turns ?? []; const userSettings = userSettingsResponse?.settings; @@ -115,13 +115,13 @@ export default async function SessionPage({ })), activeId: task.workspace_id, }, - boards: { - items: boards.map((board) => ({ - id: board.id, - workspaceId: board.workspace_id, - name: board.name, + workflows: { + items: workflows.map((workflow) => ({ + id: workflow.id, + workspaceId: workflow.workspace_id, + name: workflow.name, })), - activeId: task.board_id, + activeId: task.workflow_id, }, repositories: { itemsByWorkspaceId: { @@ -221,7 +221,8 @@ export default async function SessionPage({ }, userSettings: { workspaceId: userSettings?.workspace_id || null, - boardId: userSettings?.board_id || null, + workflowId: userSettings?.workflow_filter_id || null, + kanbanViewMode: userSettings?.kanban_view_mode || null, repositoryIds: Array.from(new Set(userSettings?.repository_ids ?? [])).sort(), preferredShell: userSettings?.preferred_shell || null, shellOptions: userSettingsResponse?.shell_options ?? [], diff --git a/apps/web/app/settings/general/editors/page.tsx b/apps/web/app/settings/general/editors/page.tsx index 2a0932508b..919d3cb09b 100644 --- a/apps/web/app/settings/general/editors/page.tsx +++ b/apps/web/app/settings/general/editors/page.tsx @@ -19,7 +19,8 @@ export default async function GeneralEditorsPage() { userSettings: settings ? { workspaceId: settings.workspace_id ?? null, - boardId: settings.board_id ?? null, + workflowId: settings.workflow_filter_id ?? null, + kanbanViewMode: settings.kanban_view_mode ?? null, repositoryIds: settings.repository_ids ?? [], preferredShell: settings.preferred_shell ?? null, shellOptions: settingsResponse.shell_options ?? [], diff --git a/apps/web/app/settings/general/page.tsx b/apps/web/app/settings/general/page.tsx index e614baf132..2218de29a7 100644 --- a/apps/web/app/settings/general/page.tsx +++ b/apps/web/app/settings/general/page.tsx @@ -11,7 +11,8 @@ export default async function GeneralSettingsPage() { userSettings: settings ? { workspaceId: settings.workspace_id ?? null, - boardId: settings.board_id ?? null, + workflowId: settings.workflow_filter_id ?? null, + kanbanViewMode: settings.kanban_view_mode ?? null, repositoryIds: settings.repository_ids ?? [], preferredShell: settings.preferred_shell ?? null, shellOptions: response.shell_options ?? [], diff --git a/apps/web/app/settings/workspace/[id]/boards/page.tsx b/apps/web/app/settings/workspace/[id]/boards/page.tsx deleted file mode 100644 index a977f6840b..0000000000 --- a/apps/web/app/settings/workspace/[id]/boards/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { - getWorkspaceAction, - listBoardsAction, - listWorkflowTemplatesAction, -} from '@/app/actions/workspaces'; -import { WorkspaceBoardsClient } from '@/app/settings/workspace/workspace-boards-client'; -import type { Board, WorkflowTemplate } from '@/lib/types/http'; - -export default async function WorkspaceBoardsPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - let workspace = null; - let boards: Board[] = []; - let workflowTemplates: WorkflowTemplate[] = []; - - try { - workspace = await getWorkspaceAction(id); - const [boardsResponse, templatesResponse] = await Promise.all([ - listBoardsAction(id), - listWorkflowTemplatesAction(), - ]); - boards = boardsResponse.boards; - workflowTemplates = templatesResponse.templates ?? []; - } catch { - workspace = null; - boards = []; - workflowTemplates = []; - } - - return ( - - ); -} diff --git a/apps/web/app/settings/workspace/[id]/workflows/page.tsx b/apps/web/app/settings/workspace/[id]/workflows/page.tsx new file mode 100644 index 0000000000..b02d1f3ec3 --- /dev/null +++ b/apps/web/app/settings/workspace/[id]/workflows/page.tsx @@ -0,0 +1,36 @@ +import { + getWorkspaceAction, + listWorkflowsAction, + listWorkflowTemplatesAction, +} from '@/app/actions/workspaces'; +import { WorkspaceWorkflowsClient } from '@/app/settings/workspace/workspace-workflows-client'; +import type { Workflow, WorkflowTemplate } from '@/lib/types/http'; + +export default async function WorkspaceWorkflowsPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + let workspace = null; + let workflows: Workflow[] = []; + let workflowTemplates: WorkflowTemplate[] = []; + + try { + workspace = await getWorkspaceAction(id); + const [workflowsResponse, templatesResponse] = await Promise.all([ + listWorkflowsAction(id), + listWorkflowTemplatesAction(), + ]); + workflows = workflowsResponse.workflows; + workflowTemplates = templatesResponse.templates ?? []; + } catch { + workspace = null; + workflows = []; + workflowTemplates = []; + } + + return ( + + ); +} diff --git a/apps/web/app/settings/workspace/workspace-boards-client.tsx b/apps/web/app/settings/workspace/workspace-boards-client.tsx deleted file mode 100644 index fbb1e328c8..0000000000 --- a/apps/web/app/settings/workspace/workspace-boards-client.tsx +++ /dev/null @@ -1,300 +0,0 @@ -'use client'; - -import { useMemo, useState } from 'react'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { IconLayoutColumns, IconPlus } from '@tabler/icons-react'; -import { Button } from '@kandev/ui/button'; -import { Card, CardContent } from '@kandev/ui/card'; -import { Separator } from '@kandev/ui/separator'; -import { Label } from '@kandev/ui/label'; -import { Input } from '@kandev/ui/input'; -import { RadioGroup, RadioGroupItem } from '@kandev/ui/radio-group'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@kandev/ui/dialog'; -import { SettingsSection } from '@/components/settings/settings-section'; -import { BoardCard } from '@/components/settings/board-card'; -import { generateUUID } from '@/lib/utils'; -import { - createBoardAction, - deleteBoardAction, - updateBoardAction, -} from '@/app/actions/workspaces'; -import type { Board, StepDefinition, WorkflowStep, Workspace, WorkflowTemplate } from '@/lib/types/http'; - -type WorkspaceBoardsClientProps = { - workspace: Workspace | null; - boards: Board[]; - workflowTemplates: WorkflowTemplate[]; -}; - -export function WorkspaceBoardsClient({ - workspace, - boards, - workflowTemplates, -}: WorkspaceBoardsClientProps) { - const router = useRouter(); - const [boardItems, setBoardItems] = useState(boards); - const [savedBoardItems, setSavedBoardItems] = useState(boards); - - // Dialog state for creating a new board - const [isAddBoardDialogOpen, setIsAddBoardDialogOpen] = useState(false); - const [newBoardName, setNewBoardName] = useState(''); - const [selectedTemplateId, setSelectedTemplateId] = useState(null); - - const templateStepsById = useMemo(() => { - return new Map( - workflowTemplates.map((template) => [template.id, template.default_steps ?? []]) - ); - }, [workflowTemplates]); - - const defaultCustomSteps = useMemo( - () => [ - { name: 'Todo', step_type: 'backlog', position: 0, color: 'bg-slate-500' }, - { name: 'In Progress', step_type: 'implementation', position: 1, color: 'bg-blue-500' }, - { name: 'Review', step_type: 'review', position: 2, color: 'bg-purple-500' }, - { name: 'Done', step_type: 'done', position: 3, color: 'bg-green-500' }, - ], - [] - ); - - const buildWorkflowSteps = (board: Board, definitions: StepDefinition[]): WorkflowStep[] => - definitions.map((step, index) => ({ - id: `temp-step-${board.id}-${index}`, - board_id: board.id, - name: step.name, - step_type: step.step_type, - position: step.position ?? index, - color: step.color ?? 'bg-slate-500', - behaviors: step.behaviors, - created_at: '', - updated_at: '', - })); - - const savedBoardsById = useMemo(() => { - return new Map(savedBoardItems.map((board) => [board.id, board])); - }, [savedBoardItems]); - - const isBoardDirty = (board: Board) => { - const saved = savedBoardsById.get(board.id); - if (!saved) return true; - if (board.name !== saved.name || board.description !== saved.description) return true; - return false; - }; - - const handleOpenAddBoardDialog = () => { - setNewBoardName(''); - setSelectedTemplateId(workflowTemplates.length > 0 ? workflowTemplates[0].id : null); - setIsAddBoardDialogOpen(true); - }; - - const handleCreateBoard = () => { - if (!workspace) return; - - const draftBoard: Board = { - id: `temp-${generateUUID()}`, - workspace_id: workspace.id, - name: newBoardName.trim() || 'New Board', - description: '', - workflow_template_id: selectedTemplateId, - created_at: '', - updated_at: '', - }; - - setBoardItems((prev) => [draftBoard, ...prev]); - setIsAddBoardDialogOpen(false); - }; - - const handleUpdateBoard = (boardId: string, updates: { name?: string; description?: string }) => { - setBoardItems((prev) => - prev.map((board) => (board.id === boardId ? { ...board, ...updates } : board)) - ); - }; - - const handleDeleteBoard = async (boardId: string) => { - if (boardId.startsWith('temp-')) { - setBoardItems((prev) => prev.filter((board) => board.id !== boardId)); - return; - } - await deleteBoardAction(boardId); - setBoardItems((prev) => prev.filter((board) => board.id !== boardId)); - setSavedBoardItems((prev) => prev.filter((board) => board.id !== boardId)); - }; - - const handleSaveBoard = async (boardId: string) => { - const board = boardItems.find((item) => item.id === boardId); - if (!board) return; - if (boardId.startsWith('temp-')) { - const name = board.name.trim() || 'New Board'; - const createdBoard = await createBoardAction({ - workspace_id: workspace?.id ?? board.workspace_id, - name, - workflow_template_id: board.workflow_template_id ?? undefined, - }); - // Backend creates workflow steps automatically from the template - setBoardItems((prev) => prev.map((item) => (item.id === boardId ? createdBoard : item))); - setSavedBoardItems((prev) => [{ ...createdBoard }, ...prev]); - // Refresh the page to load the workflow steps created by the backend - router.refresh(); - return; - } - // For existing boards, only update board name/description - const updates: { name?: string; description?: string } = {}; - if (board.name.trim()) { - updates.name = board.name.trim(); - } - if (Object.keys(updates).length) { - await updateBoardAction(boardId, updates); - } - setBoardItems((prev) => - prev.map((item) => (item.id === boardId ? { ...item, ...updates } : item)) - ); - setSavedBoardItems((prev) => - prev.some((item) => item.id === boardId) - ? prev.map((item) => - item.id === boardId ? { ...board, ...updates } : item - ) - : [...prev, { ...board, ...updates }] - ); - }; - - if (!workspace) { - return ( -
- - -

Workspace not found

- -
-
-
- ); - } - - return ( -
-
-
-

{workspace.name}

-

- Manage boards for this workspace. -

-
- -
- - - - } - title="Boards" - description="Boards in this workspace" - action={ - - } - > -
- {boardItems.map((board) => { - const isTempBoard = board.id.startsWith('temp-'); - const templateSteps = - isTempBoard && board.workflow_template_id - ? templateStepsById.get(board.workflow_template_id) ?? [] - : defaultCustomSteps; - const initialWorkflowSteps = - isTempBoard && templateSteps.length - ? buildWorkflowSteps(board, templateSteps) - : undefined; - return ( - handleUpdateBoard(board.id, updates)} - onDeleteBoard={() => handleDeleteBoard(board.id)} - onSaveBoard={() => handleSaveBoard(board.id)} - /> - ); - })} -
-
- - {/* Create Board Dialog */} - - - - Create Board - - -
-
- - setNewBoardName(e.target.value)} - /> -
- - {workflowTemplates.length > 0 && ( -
- - - setSelectedTemplateId(value === 'custom' ? null : value) - } - > - {workflowTemplates.map((template) => ( -
- - -
- ))} -
- - -
-
-
- )} - - {/* Workflow preview intentionally omitted from the modal */} -
- - - - - -
-
-
- ); -} diff --git a/apps/web/app/settings/workspace/workspace-edit-client.tsx b/apps/web/app/settings/workspace/workspace-edit-client.tsx index 53c765b63f..dc71dbc0f5 100644 --- a/apps/web/app/settings/workspace/workspace-edit-client.tsx +++ b/apps/web/app/settings/workspace/workspace-edit-client.tsx @@ -61,47 +61,66 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { const router = useRouter(); const { toast } = useToast(); const [currentWorkspace, setCurrentWorkspace] = useState(workspace); + + // Draft state for all fields const [workspaceNameDraft, setWorkspaceNameDraft] = useState(workspace.name ?? ''); - const [savedWorkspaceName, setSavedWorkspaceName] = useState(workspace.name ?? ''); - const [savedDefaultExecutorId, setSavedDefaultExecutorId] = useState( - workspace.default_executor_id ?? '' - ); - const [savedDefaultEnvironmentId, setSavedDefaultEnvironmentId] = useState( - workspace.default_environment_id ?? '' - ); - const [savedDefaultAgentProfileId, setSavedDefaultAgentProfileId] = useState( - workspace.default_agent_profile_id ?? '' - ); + const [defaultExecutorId, setDefaultExecutorId] = useState(workspace.default_executor_id ?? ''); + const [defaultEnvironmentId, setDefaultEnvironmentId] = useState(workspace.default_environment_id ?? ''); + const [defaultAgentProfileId, setDefaultAgentProfileId] = useState(workspace.default_agent_profile_id ?? ''); + + // Saved state to track dirty + const [savedState, setSavedState] = useState({ + name: workspace.name ?? '', + executorId: workspace.default_executor_id ?? '', + environmentId: workspace.default_environment_id ?? '', + agentProfileId: workspace.default_agent_profile_id ?? '', + }); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteConfirmText, setDeleteConfirmText] = useState(''); - const [defaultExecutorId, setDefaultExecutorId] = useState( - workspace.default_executor_id ?? '' - ); - const [defaultEnvironmentId, setDefaultEnvironmentId] = useState( - workspace.default_environment_id ?? '' - ); - const [defaultAgentProfileId, setDefaultAgentProfileId] = useState( - workspace.default_agent_profile_id ?? '' - ); + const executors = useAppStore((state) => state.executors.items); const environments = useAppStore((state) => state.environments.items); const agentProfiles = useAppStore((state) => state.agentProfiles.items); - const saveWorkspaceRequest = useRequest(updateWorkspaceAction); - const deleteWorkspaceRequest = useRequest(deleteWorkspaceAction); const workspaces = useAppStore((state) => state.workspaces.items); const setWorkspaces = useAppStore((state) => state.setWorkspaces); - const activeExecutors = executors.filter((executor: Executor) => executor.status === 'active'); - const isDefaultExecutorDirty = defaultExecutorId !== savedDefaultExecutorId; - const isDefaultEnvironmentDirty = defaultEnvironmentId !== savedDefaultEnvironmentId; - const isDefaultAgentProfileDirty = defaultAgentProfileId !== savedDefaultAgentProfileId; - const handleSaveWorkspaceName = async () => { - const trimmed = workspaceNameDraft.trim(); - if (!trimmed || trimmed === currentWorkspace.name) return; + const saveWorkspaceRequest = useRequest(updateWorkspaceAction); + const deleteWorkspaceRequest = useRequest(deleteWorkspaceAction); + + const activeExecutors = executors.filter((executor: Executor) => executor.status === 'active'); + + const isDirty = + workspaceNameDraft.trim() !== savedState.name || + defaultExecutorId !== savedState.executorId || + defaultEnvironmentId !== savedState.environmentId || + defaultAgentProfileId !== savedState.agentProfileId; + + const handleSave = async () => { + if (!isDirty) return; try { - const updated = await saveWorkspaceRequest.run(currentWorkspace.id, { name: trimmed }); + const updates: Record = {}; + if (workspaceNameDraft.trim() !== savedState.name) { + updates.name = workspaceNameDraft.trim(); + } + if (defaultExecutorId !== savedState.executorId) { + updates.default_executor_id = defaultExecutorId; + } + if (defaultEnvironmentId !== savedState.environmentId) { + updates.default_environment_id = defaultEnvironmentId; + } + if (defaultAgentProfileId !== savedState.agentProfileId) { + updates.default_agent_profile_id = defaultAgentProfileId; + } + + const updated = await saveWorkspaceRequest.run(currentWorkspace.id, updates); setCurrentWorkspace((prev) => ({ ...prev, ...updated })); - setSavedWorkspaceName(updated.name); + setSavedState({ + name: updated.name ?? workspaceNameDraft.trim(), + executorId: updated.default_executor_id ?? '', + environmentId: updated.default_environment_id ?? '', + agentProfileId: updated.default_agent_profile_id ?? '', + }); setWorkspaces( workspaces.map((workspaceItem: Workspace) => workspaceItem.id === updated.id @@ -139,99 +158,12 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { } }; - const handleSaveDefaultExecutor = async () => { - if (defaultExecutorId === savedDefaultExecutorId) return; - try { - const updated = await saveWorkspaceRequest.run(currentWorkspace.id, { - default_executor_id: defaultExecutorId, - }); - setCurrentWorkspace((prev) => ({ ...prev, ...updated })); - setSavedDefaultExecutorId(updated.default_executor_id ?? ''); - setWorkspaces( - workspaces.map((workspaceItem: Workspace) => - workspaceItem.id === updated.id - ? { - ...workspaceItem, - default_executor_id: updated.default_executor_id ?? null, - default_environment_id: updated.default_environment_id ?? null, - default_agent_profile_id: updated.default_agent_profile_id ?? null, - } - : workspaceItem - ) - ); - } catch (error) { - toast({ - title: 'Failed to save executor', - description: error instanceof Error ? error.message : 'Request failed', - variant: 'error', - }); - } - }; - - const handleSaveDefaultEnvironment = async () => { - if (defaultEnvironmentId === savedDefaultEnvironmentId) return; - try { - const updated = await saveWorkspaceRequest.run(currentWorkspace.id, { - default_environment_id: defaultEnvironmentId, - }); - setCurrentWorkspace((prev) => ({ ...prev, ...updated })); - setSavedDefaultEnvironmentId(updated.default_environment_id ?? ''); - setWorkspaces( - workspaces.map((workspaceItem: Workspace) => - workspaceItem.id === updated.id - ? { - ...workspaceItem, - default_executor_id: updated.default_executor_id ?? null, - default_environment_id: updated.default_environment_id ?? null, - default_agent_profile_id: updated.default_agent_profile_id ?? null, - } - : workspaceItem - ) - ); - } catch (error) { - toast({ - title: 'Failed to save environment', - description: error instanceof Error ? error.message : 'Request failed', - variant: 'error', - }); - } - }; - - const handleSaveDefaultAgentProfile = async () => { - if (defaultAgentProfileId === savedDefaultAgentProfileId) return; - try { - const updated = await saveWorkspaceRequest.run(currentWorkspace.id, { - default_agent_profile_id: defaultAgentProfileId, - }); - setCurrentWorkspace((prev) => ({ ...prev, ...updated })); - setSavedDefaultAgentProfileId(updated.default_agent_profile_id ?? ''); - setWorkspaces( - workspaces.map((workspaceItem: Workspace) => - workspaceItem.id === updated.id - ? { - ...workspaceItem, - default_executor_id: updated.default_executor_id ?? null, - default_environment_id: updated.default_environment_id ?? null, - default_agent_profile_id: updated.default_agent_profile_id ?? null, - } - : workspaceItem - ) - ); - } catch (error) { - toast({ - title: 'Failed to save agent profile', - description: error instanceof Error ? error.message : 'Request failed', - variant: 'error', - }); - } - }; - return (

{currentWorkspace.name}

- Manage workspace details and jump into boards or repositories. + Manage workspace details and jump into workflows or repositories.

@@ -240,148 +172,108 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { - Workspace Name - {workspaceNameDraft.trim() !== savedWorkspaceName && } + Workspace Settings + {isDirty && } -
- setWorkspaceNameDraft(event.target.value)} - /> - +
+
+ + setWorkspaceNameDraft(event.target.value)} + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
- - - - Default Executor - {isDefaultExecutorDirty && } - - - -
- - -
-
-
- - - - - Default Environment - {isDefaultEnvironmentDirty && } - - - -
- - -
-
-
- - - - - Default Agent Profile - {isDefaultAgentProfileDirty && } - - - -
- - -
-
-
- - - Workspace Links @@ -395,9 +287,9 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) {
@@ -416,7 +308,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) {

Delete this workspace

This action cannot be undone.

- @@ -442,13 +334,14 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { />
- diff --git a/apps/web/app/settings/workspace/workspace-workflows-client.tsx b/apps/web/app/settings/workspace/workspace-workflows-client.tsx new file mode 100644 index 0000000000..225d87450a --- /dev/null +++ b/apps/web/app/settings/workspace/workspace-workflows-client.tsx @@ -0,0 +1,329 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { IconLayoutColumns, IconPlus } from '@tabler/icons-react'; +import { Button } from '@kandev/ui/button'; +import { Card, CardContent } from '@kandev/ui/card'; +import { Separator } from '@kandev/ui/separator'; +import { Label } from '@kandev/ui/label'; +import { Input } from '@kandev/ui/input'; +import { RadioGroup, RadioGroupItem } from '@kandev/ui/radio-group'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@kandev/ui/dialog'; +import { SettingsSection } from '@/components/settings/settings-section'; +import { WorkflowCard } from '@/components/settings/workflow-card'; +import { cn, generateUUID } from '@/lib/utils'; +import { + deleteWorkflowAction, + updateWorkflowAction, +} from '@/app/actions/workspaces'; +import type { Workflow, StepDefinition, WorkflowStep, Workspace, WorkflowTemplate } from '@/lib/types/http'; + +type WorkspaceWorkflowsClientProps = { + workspace: Workspace | null; + workflows: Workflow[]; + workflowTemplates: WorkflowTemplate[]; +}; + +export function WorkspaceWorkflowsClient({ + workspace, + workflows, + workflowTemplates, +}: WorkspaceWorkflowsClientProps) { + const router = useRouter(); + const [workflowItems, setWorkflowItems] = useState(workflows); + const [savedWorkflowItems, setSavedWorkflowItems] = useState(workflows); + + // Dialog state for creating a new workflow + const [isAddWorkflowDialogOpen, setIsAddWorkflowDialogOpen] = useState(false); + const [newWorkflowName, setNewWorkflowName] = useState(''); + const [selectedTemplateId, setSelectedTemplateId] = useState(null); + + const templateStepsById = useMemo(() => { + return new Map( + workflowTemplates.map((template) => [template.id, template.default_steps ?? []]) + ); + }, [workflowTemplates]); + + const defaultCustomSteps = useMemo( + () => [ + { name: 'Todo', position: 0, color: 'bg-slate-500' }, + { name: 'In Progress', position: 1, color: 'bg-blue-500' }, + { name: 'Review', position: 2, color: 'bg-purple-500' }, + { name: 'Done', position: 3, color: 'bg-green-500' }, + ], + [] + ); + + const buildWorkflowSteps = (workflow: Workflow, definitions: StepDefinition[]): WorkflowStep[] => + definitions.map((step, index) => ({ + id: `temp-step-${workflow.id}-${index}`, + workflow_id: workflow.id, + name: step.name, + position: step.position ?? index, + color: step.color ?? 'bg-slate-500', + prompt: step.prompt, + events: step.events, + is_start_step: step.is_start_step, + allow_manual_move: true, + created_at: '', + updated_at: '', + })); + + const savedWorkflowsById = useMemo(() => { + return new Map(savedWorkflowItems.map((workflow) => [workflow.id, workflow])); + }, [savedWorkflowItems]); + + const isWorkflowDirty = (workflow: Workflow) => { + const saved = savedWorkflowsById.get(workflow.id); + if (!saved) return true; + if (workflow.name !== saved.name || workflow.description !== saved.description) return true; + return false; + }; + + const handleOpenAddWorkflowDialog = () => { + setNewWorkflowName(''); + setSelectedTemplateId(workflowTemplates.length > 0 ? workflowTemplates[0].id : null); + setIsAddWorkflowDialogOpen(true); + }; + + const handleCreateWorkflow = () => { + if (!workspace) return; + + const draftWorkflow: Workflow = { + id: `temp-${generateUUID()}`, + workspace_id: workspace.id, + name: newWorkflowName.trim() || (selectedTemplateId ? workflowTemplates.find((t) => t.id === selectedTemplateId)?.name ?? 'New Workflow' : 'New Workflow'), + description: '', + workflow_template_id: selectedTemplateId, + created_at: '', + updated_at: '', + }; + + setWorkflowItems((prev) => [draftWorkflow, ...prev]); + setIsAddWorkflowDialogOpen(false); + }; + + const handleUpdateWorkflow = (workflowId: string, updates: { name?: string; description?: string }) => { + setWorkflowItems((prev) => + prev.map((workflow) => (workflow.id === workflowId ? { ...workflow, ...updates } : workflow)) + ); + }; + + const handleDeleteWorkflow = async (workflowId: string) => { + if (workflowId.startsWith('temp-')) { + setWorkflowItems((prev) => prev.filter((workflow) => workflow.id !== workflowId)); + return; + } + await deleteWorkflowAction(workflowId); + setWorkflowItems((prev) => prev.filter((workflow) => workflow.id !== workflowId)); + setSavedWorkflowItems((prev) => prev.filter((workflow) => workflow.id !== workflowId)); + }; + + const handleWorkflowCreated = (tempId: string, created: Workflow) => { + setWorkflowItems((prev) => prev.map((item) => (item.id === tempId ? created : item))); + setSavedWorkflowItems((prev) => [{ ...created }, ...prev]); + router.refresh(); + }; + + const handleSaveWorkflow = async (workflowId: string) => { + const workflow = workflowItems.find((item) => item.id === workflowId); + if (!workflow) return; + // For existing workflows, only update workflow name/description + const updates: { name?: string; description?: string } = {}; + if (workflow.name.trim()) { + updates.name = workflow.name.trim(); + } + if (Object.keys(updates).length) { + await updateWorkflowAction(workflowId, updates); + } + setWorkflowItems((prev) => + prev.map((item) => (item.id === workflowId ? { ...item, ...updates } : item)) + ); + setSavedWorkflowItems((prev) => + prev.some((item) => item.id === workflowId) + ? prev.map((item) => + item.id === workflowId ? { ...workflow, ...updates } : item + ) + : [...prev, { ...workflow, ...updates }] + ); + }; + + if (!workspace) { + return ( +
+ + +

Workspace not found

+ +
+
+
+ ); + } + + return ( +
+
+
+

{workspace.name}

+

+ Manage workflows for this workspace. +

+
+ +
+ + + + } + title="Workflows" + description="Create autonomous pipelines with automated transitions or manual workflows where you move tasks yourself" + action={ + + } + > +
+ {workflowItems.map((workflow) => { + const isTempWorkflow = workflow.id.startsWith('temp-'); + const templateSteps = + isTempWorkflow && workflow.workflow_template_id + ? templateStepsById.get(workflow.workflow_template_id) ?? [] + : defaultCustomSteps; + const initialWorkflowSteps = + isTempWorkflow && templateSteps.length + ? buildWorkflowSteps(workflow, templateSteps) + : undefined; + return ( + w.id !== workflow.id && !w.id.startsWith('temp-') + )} + onUpdateWorkflow={(updates) => handleUpdateWorkflow(workflow.id, updates)} + onDeleteWorkflow={() => handleDeleteWorkflow(workflow.id)} + onSaveWorkflow={() => handleSaveWorkflow(workflow.id)} + onWorkflowCreated={(created) => handleWorkflowCreated(workflow.id, created)} + /> + ); + })} +
+
+ + {/* Create Workflow Dialog */} + + + + Add Workflow + + +
+
+ + setNewWorkflowName(e.target.value)} + /> +
+ + {workflowTemplates.length > 0 && ( +
+ + + setSelectedTemplateId(value === 'custom' ? null : value) + } + > +
+ {workflowTemplates.map((template) => ( +
+ )} + + {/* Workflow preview intentionally omitted from the modal */} +
+ + + + + + +
+
+ ); +} diff --git a/apps/web/app/settings/workspace/workspaces-page-client.tsx b/apps/web/app/settings/workspace/workspaces-page-client.tsx index a610a5d8e4..e9ece41520 100644 --- a/apps/web/app/settings/workspace/workspaces-page-client.tsx +++ b/apps/web/app/settings/workspace/workspaces-page-client.tsx @@ -69,7 +69,7 @@ export function WorkspacesPageClient() {

Workspaces

- Manage your workspaces and boards + Manage your workspaces and workflows

diff --git a/apps/web/app/tasks/columns.tsx b/apps/web/app/tasks/columns.tsx index 69f4208e25..47718ea89f 100644 --- a/apps/web/app/tasks/columns.tsx +++ b/apps/web/app/tasks/columns.tsx @@ -1,7 +1,7 @@ "use client" import type { ColumnDef } from "@tanstack/react-table" -import type { Task, Board, WorkflowStep, Repository } from "@/lib/types/http" +import type { Task, Workflow, WorkflowStep, Repository } from "@/lib/types/http" import Link from "next/link" import { IconTrash, IconLoader } from "@tabler/icons-react" import { Button } from "@kandev/ui/button" @@ -10,13 +10,13 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kande import { formatDistanceToNow } from "date-fns" type TaskWithResolution = Task & { - boardName?: string + workflowName?: string stepName?: string repositoryNames?: string[] } interface ColumnsConfig { - boards: Board[] + workflows: Workflow[] steps: WorkflowStep[] repositories: Repository[] onDelete: (taskId: string) => void @@ -24,13 +24,13 @@ interface ColumnsConfig { } export function getColumns({ - boards, + workflows, steps, repositories, onDelete, deletingTaskId, }: ColumnsConfig): ColumnDef[] { - const boardMap = new Map(boards.map((b) => [b.id, b.name])) + const workflowMap = new Map(workflows.map((w) => [w.id, w.name])) const stepMap = new Map(steps.map((s) => [s.id, s.name])) const repoMap = new Map(repositories.map((r) => [r.id, { name: r.name, path: r.local_path }])) @@ -55,11 +55,11 @@ export function getColumns({ }, }, { - accessorKey: "board_id", - header: "Board", + accessorKey: "workflow_id", + header: "Workflow", cell: ({ row }) => { - const boardName = boardMap.get(row.original.board_id) - return boardName || "-" + const workflowName = workflowMap.get(row.original.workflow_id) + return workflowName || "-" }, }, { diff --git a/apps/web/app/tasks/page.tsx b/apps/web/app/tasks/page.tsx index 54659ef0d3..0f79ebc223 100644 --- a/apps/web/app/tasks/page.tsx +++ b/apps/web/app/tasks/page.tsx @@ -1,12 +1,12 @@ import { listWorkspacesAction, - listBoardsAction, + listWorkflowsAction, listTasksByWorkspaceAction, listRepositoriesAction, listWorkflowStepsAction, } from '@/app/actions/workspaces'; import { TasksPageClient } from './tasks-page-client'; -import type { Board, Task, WorkflowStep, Repository, Workspace } from '@/lib/types/http'; +import type { Workflow, Task, WorkflowStep, Repository, Workspace } from '@/lib/types/http'; export default async function TasksPage({ searchParams, @@ -16,7 +16,7 @@ export default async function TasksPage({ const { workspace: workspaceParam } = await searchParams; let workspaces: Workspace[] = []; - let boards: Board[] = []; + let workflows: Workflow[] = []; let steps: WorkflowStep[] = []; let repositories: Repository[] = []; let tasks: Task[] = []; @@ -34,20 +34,20 @@ export default async function TasksPage({ if (workspaceId) { // Fetch all data in parallel - const [boardsResponse, repositoriesResponse, tasksResponse] = await Promise.all([ - listBoardsAction(workspaceId), + const [workflowsResponse, repositoriesResponse, tasksResponse] = await Promise.all([ + listWorkflowsAction(workspaceId), listRepositoriesAction(workspaceId), listTasksByWorkspaceAction(workspaceId, 1, 25), ]); - boards = boardsResponse.boards; + workflows = workflowsResponse.workflows; repositories = repositoriesResponse.repositories; tasks = tasksResponse.tasks; total = tasksResponse.total; - // Fetch workflow steps for each board + // Fetch workflow steps for each workflow const stepsResponses = await Promise.all( - boards.map((board) => listWorkflowStepsAction(board.id)) + workflows.map((workflow) => listWorkflowStepsAction(workflow.id)) ); steps = stepsResponses.flatMap((r) => r.steps); } @@ -59,7 +59,7 @@ export default async function TasksPage({ 0 ? storeRepositories : initialRepositories @@ -128,13 +128,13 @@ export function TasksPageClient({ const columns = useMemo( () => getColumns({ - boards, + workflows, steps, repositories, onDelete: handleDelete, deletingTaskId, }), - [boards, steps, repositories, handleDelete, deletingTaskId] + [workflows, steps, repositories, handleDelete, deletingTaskId] ) const handleRowClick = useCallback( @@ -151,10 +151,10 @@ export function TasksPageClient({ [router, toast] ) - const defaultBoard = activeBoardId - ? boards.find((b) => b.id === activeBoardId) - : boards[0] - const defaultColumn = steps.find((s) => s.board_id === defaultBoard?.id) + const defaultWorkflow = activeWorkflowId + ? workflows.find((w) => w.id === activeWorkflowId) + : workflows[0] + const defaultStep = steps.find((s) => s.workflow_id === defaultWorkflow?.id) return (
@@ -185,16 +185,16 @@ export function TasksPageClient({ />
- {activeWorkspaceId && defaultBoard && defaultColumn && ( + {activeWorkspaceId && defaultWorkflow && defaultStep && ( s.board_id === defaultBoard.id) - .map((s) => ({ id: s.id, title: s.name, autoStartAgent: s.behaviors?.autoStartAgent }))} + workflowId={defaultWorkflow.id} + defaultStepId={defaultStep.id} + steps={steps + .filter((s) => s.workflow_id === defaultWorkflow.id) + .map((s) => ({ id: s.id, title: s.name, events: s.events }))} onSuccess={() => { setCreateDialogOpen(false) fetchTasks() diff --git a/apps/web/components/diff/diff-viewer-resolver.tsx b/apps/web/components/diff/diff-viewer-resolver.tsx index b981d5d987..f995db1a51 100644 --- a/apps/web/components/diff/diff-viewer-resolver.tsx +++ b/apps/web/components/diff/diff-viewer-resolver.tsx @@ -24,7 +24,7 @@ interface DiffViewerResolverProps { export const DiffViewerResolved = memo(function DiffViewerResolved(props: DiffViewerResolverProps) { const provider = useEditorProvider('diff-viewer'); if (provider === 'monaco') { - const { enableComments: _, ...rest } = props; + const { enableComments: _enableComments, ...rest } = props; return ; } return ; diff --git a/apps/web/components/editors/monaco/monaco-code-editor.tsx b/apps/web/components/editors/monaco/monaco-code-editor.tsx index 1716d86320..28e9f592c4 100644 --- a/apps/web/components/editors/monaco/monaco-code-editor.tsx +++ b/apps/web/components/editors/monaco/monaco-code-editor.tsx @@ -250,7 +250,6 @@ export function MonacoCodeEditor({ // Cmd/Ctrl+S to save editor.addCommand( - // eslint-disable-next-line no-bitwise (window as { monaco?: typeof import('monaco-editor') }).monaco!.KeyMod.CtrlCmd | (window as { monaco?: typeof import('monaco-editor') }).monaco!.KeyCode.KeyS, () => { diff --git a/apps/web/components/kanban-board-grid.tsx b/apps/web/components/kanban-board-grid.tsx index 7ad6244d6c..782f87484f 100644 --- a/apps/web/components/kanban-board-grid.tsx +++ b/apps/web/components/kanban-board-grid.tsx @@ -21,13 +21,13 @@ import { useResponsiveBreakpoint } from '@/hooks/use-responsive-breakpoint'; import { useAppStore } from '@/components/state-provider'; export type KanbanBoardGridProps = { - columns: WorkflowStep[]; + steps: WorkflowStep[]; tasks: Task[]; onPreviewTask: (task: Task) => void; onOpenTask: (task: Task) => void; onEditTask: (task: Task) => void; onDeleteTask: (task: Task) => void; - onMoveTask?: (task: Task, targetColumnId: string) => void; + onMoveTask?: (task: Task, targetStepId: string) => void; onDragStart: (event: DragStartEvent) => void; onDragEnd: (event: DragEndEvent) => void; onDragCancel: () => void; @@ -39,7 +39,7 @@ export type KanbanBoardGridProps = { }; export function KanbanBoardGrid({ - columns, + steps, tasks, onPreviewTask, onOpenTask, @@ -74,58 +74,58 @@ export function KanbanBoardGrid({ }) ); - const getTasksForColumn = (columnId: string) => { + const getTasksForStep = (stepId: string) => { return tasks - .filter((task) => task.workflowStepId === columnId) + .filter((task) => task.workflowStepId === stepId) .map((task) => ({ ...task, position: task.position ?? 0 })) .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); }; - // Calculate task counts per column for tabs + // Calculate task counts per step for tabs const taskCounts = useMemo(() => { const counts: Record = {}; - for (const column of columns) { - counts[column.id] = tasks.filter((task) => task.workflowStepId === column.id).length; + for (const step of steps) { + counts[step.id] = tasks.filter((task) => task.workflowStepId === step.id).length; } return counts; - }, [columns, tasks]); + }, [steps, tasks]); - // On mobile, select the first column with tasks on initial load + // On mobile, select the first step with tasks on initial load const hasInitializedRef = useRef(false); useEffect(() => { - if (!isMobile || hasInitializedRef.current || columns.length === 0) return; + if (!isMobile || hasInitializedRef.current || steps.length === 0) return; - const firstColumnWithTasks = columns.findIndex( - (column) => taskCounts[column.id] > 0 + const firstStepWithTasks = steps.findIndex( + (step) => taskCounts[step.id] > 0 ); - if (firstColumnWithTasks !== -1 && firstColumnWithTasks !== activeColumnIndex) { - setActiveColumnIndex(firstColumnWithTasks); + if (firstStepWithTasks !== -1 && firstStepWithTasks !== activeColumnIndex) { + setActiveColumnIndex(firstStepWithTasks); } hasInitializedRef.current = true; - }, [isMobile, columns, taskCounts, activeColumnIndex, setActiveColumnIndex]); + }, [isMobile, steps, taskCounts, activeColumnIndex, setActiveColumnIndex]); - // Get current column ID for mobile drop targets - const currentColumnId = columns[activeColumnIndex]?.id ?? null; + // Get current step ID for mobile drop targets + const currentStepId = steps[activeColumnIndex]?.id ?? null; - // Check if we have a board selected (from SSR or client) - const boardsActiveId = useAppStore((state) => state.boards.activeId); - const kanbanBoardId = useAppStore((state) => state.kanban.boardId); + // Check if we have a workflow selected (from SSR or client) + const workflowsActiveId = useAppStore((state) => state.workflows.activeId); + const kanbanWorkflowId = useAppStore((state) => state.kanban.workflowId); // Show loading state when: // 1. isLoading is explicitly true, or - // 2. We have an active board selected but the kanban data hasn't been hydrated yet - // (boards.activeId is set but kanban.boardId is null - waiting for snapshot) - // 3. isLoading is undefined AND no columns AND no board selected + // 2. We have an active workflow selected but the kanban data hasn't been hydrated yet + // (workflows.activeId is set but kanban.workflowId is null - waiting for snapshot) + // 3. isLoading is undefined AND no columns AND no workflow selected // (still initializing before any SSR data) const showLoading = isLoading === true || - (boardsActiveId && !kanbanBoardId) || - (isLoading === undefined && columns.length === 0 && !boardsActiveId); + (workflowsActiveId && !kanbanWorkflowId) || + (isLoading === undefined && steps.length === 0 && !workflowsActiveId); const renderEmptyState = () => (
- {showLoading ? 'Loading...' : 'No boards available yet.'} + {showLoading ? 'Loading...' : 'No workflows available yet.'}
); @@ -139,18 +139,18 @@ export function KanbanBoardGrid({ onDragCancel={onDragCancel} >
- {showLoading || columns.length === 0 ? ( + {showLoading || steps.length === 0 ? ( renderEmptyState() ) : ( <> @@ -192,24 +192,24 @@ export function KanbanBoardGrid({ onDragCancel={onDragCancel} >
- {showLoading || columns.length === 0 ? ( + {showLoading || steps.length === 0 ? ( renderEmptyState() ) : (
- {columns.map((column) => ( + {steps.map((step) => (
@@ -234,24 +234,24 @@ export function KanbanBoardGrid({ onDragCancel={onDragCancel} >
- {showLoading || columns.length === 0 ? ( + {showLoading || steps.length === 0 ? ( renderEmptyState() ) : (
- {columns.map((column) => ( + {steps.map((step) => ( diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 536ef312dc..5a295e5b85 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -6,11 +6,12 @@ import { Task } from './kanban-card'; import { TaskCreateDialog } from './task-create-dialog'; import { useAppStore, useAppStoreApi } from '@/components/state-provider'; import type { Task as BackendTask } from '@/lib/types/http'; -import type { BoardState } from '@/lib/state/slices'; -import { useDragAndDrop, type WorkflowAutomation, type MoveTaskError } from '@/hooks/use-drag-and-drop'; -import { KanbanBoardGrid } from './kanban-board-grid'; +import type { WorkflowsState } from '@/lib/state/slices'; +import { type WorkflowAutomation, type MoveTaskError } from '@/hooks/use-drag-and-drop'; +import { SwimlaneContainer } from './kanban/swimlane-container'; import { KanbanHeader } from './kanban/kanban-header'; import { useKanbanData, useKanbanActions, useKanbanNavigation } from '@/hooks/domains/kanban'; +import { useAllWorkflowSnapshots } from '@/hooks/domains/kanban/use-all-workflow-snapshots'; import { useResponsiveBreakpoint } from '@/hooks/use-responsive-breakpoint'; import { getWebSocketClient } from '@/lib/ws/connection'; import { linkToSession } from '@/lib/links'; @@ -40,12 +41,18 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} // Search state const [searchQuery, setSearchQuery] = useState(''); + // View mode from user settings + const kanbanViewMode = useAppStore((state) => state.userSettings.kanbanViewMode); + // Get initial state for actions hook const kanban = useAppStore((state) => state.kanban); const workspaceState = useAppStore((state) => state.workspaces); - const boardsState = useAppStore((state) => state.boards); - const setActiveBoard = useAppStore((state) => state.setActiveBoard); - const setBoards = useAppStore((state) => state.setBoards); + const workflowsState = useAppStore((state) => state.workflows); + const setActiveWorkflow = useAppStore((state) => state.setActiveWorkflow); + const setWorkflows = useAppStore((state) => state.setWorkflows); + + // Load all workflow snapshots for swimlane views + useAllWorkflowSnapshots(workspaceState.activeId); // Consolidated actions hook const { @@ -59,22 +66,21 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} handleDialogOpenChange, handleDialogSuccess, handleWorkspaceChange, - handleBoardChange, + handleWorkflowChange, deletingTaskId, - } = useKanbanActions({ workspaceState, boardsState }); + } = useKanbanActions({ workspaceState, workflowsState }); // Data fetching and derived state const { enablePreviewOnClick, userSettings, commitSettings, - activeColumns, - visibleTasksWithSessions, + activeSteps, isMounted, setTaskSessionAvailability, } = useKanbanData({ onWorkspaceChange: handleWorkspaceChange, - onBoardChange: handleBoardChange, + onWorkflowChange: handleWorkflowChange, searchQuery, }); @@ -146,56 +152,44 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} [workflowAutomation] ); - // Drag and drop with workflow automation callback - const { activeTask, handleDragStart, handleDragEnd, handleDragCancel, moveTaskToColumn } = useDragAndDrop({ - visibleTasks: visibleTasksWithSessions, - onWorkflowAutomation: handleWorkflowAutomation, - onMoveError: handleMoveError, - }); - - // Ref for userSettings to avoid reactive deps in board selection effect + // Ref for userSettings to avoid reactive deps in workflow selection effect const userSettingsRef = useRef(userSettings); useEffect(() => { userSettingsRef.current = userSettings; }); - // Board selection effect + // Workflow selection effect useEffect(() => { const workspaceId = workspaceState.activeId; if (!workspaceId) { - if (boardsState.items.length || boardsState.activeId) { - setBoards([]); - setActiveBoard(null); + if (workflowsState.items.length || workflowsState.activeId) { + setWorkflows([]); + setActiveWorkflow(null); } return; } const settings = userSettingsRef.current; - const workspaceBoards = boardsState.items.filter( - (board: BoardState['items'][number]) => board.workspaceId === workspaceId + const workspaceWorkflows = workflowsState.items.filter( + (workflow: WorkflowsState['items'][number]) => workflow.workspaceId === workspaceId ); - const desiredBoardId = - (settings.boardId && workspaceBoards.some((board: BoardState['items'][number]) => board.id === settings.boardId) - ? settings.boardId - : workspaceBoards[0]?.id) ?? null; - setActiveBoard(desiredBoardId); - if (settings.loaded && desiredBoardId !== settings.boardId) { - commitSettings({ - workspaceId, - boardId: desiredBoardId, - repositoryIds: settings.repositoryIds, - }); - } - if (!desiredBoardId) { + + // Default to "All Workflows" (null) unless settings explicitly specify a workflow + const desiredWorkflowId = + settings.workflowId && workspaceWorkflows.some((workflow: WorkflowsState['items'][number]) => workflow.id === settings.workflowId) + ? settings.workflowId + : null; + setActiveWorkflow(desiredWorkflowId); + if (!desiredWorkflowId) { store.getState().hydrate({ - kanban: { boardId: null, steps: [], tasks: [] }, + kanban: { workflowId: null, steps: [], tasks: [] }, }); } }, [ - boardsState.activeId, - boardsState.items, + workflowsState.activeId, + workflowsState.items, commitSettings, - setActiveBoard, - setBoards, + setActiveWorkflow, + setWorkflows, store, workspaceState.activeId, ]); @@ -218,9 +212,9 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} open={isDialogOpen} onOpenChange={handleDialogOpenChange} workspaceId={workspaceState.activeId} - boardId={kanban.boardId} - defaultColumnId={activeColumns[0]?.id ?? null} - columns={activeColumns.map((column) => ({ id: column.id, title: column.title, autoStartAgent: column.autoStartAgent }))} + workflowId={kanban.workflowId} + defaultStepId={activeSteps[0]?.id ?? null} + steps={activeSteps.map((step) => ({ id: step.id, title: step.title, events: step.events }))} editingTask={ editingTask ? { @@ -255,9 +249,9 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} }} mode="session" workspaceId={workspaceState.activeId} - boardId={kanban.boardId} - defaultColumnId={workflowAutomation?.workflowStep.id ?? null} - columns={activeColumns.map((column) => ({ id: column.id, title: column.title, autoStartAgent: column.autoStartAgent }))} + workflowId={kanban.workflowId} + defaultStepId={workflowAutomation?.workflowStep.id ?? null} + steps={activeSteps.map((step) => ({ id: step.id, title: step.title, events: step.events }))} editingTask={null} onCreateSession={handleWorkflowSessionCreate} initialValues={{ @@ -287,22 +281,18 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} -
); diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 72ac3ed04f..a74d7637f0 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -19,6 +19,7 @@ import { import type { TaskState, Repository } from '@/lib/types/http'; import { cn, getRepositoryDisplayName } from '@/lib/utils'; import { getTaskStateIcon } from '@/lib/ui/state-icons'; +import { needsAction } from '@/lib/utils/needs-action'; import { useAppStore } from '@/components/state-provider'; export interface Task { @@ -34,9 +35,10 @@ export interface Task { sessionCount?: number | null; primarySessionId?: string | null; reviewStatus?: 'pending' | 'approved' | 'changes_requested' | 'rejected' | null; + updatedAt?: string; } -export interface WorkflowColumn { +export interface WorkflowStep { id: string; title: string; color: string; @@ -49,8 +51,8 @@ interface KanbanCardProps { onEdit?: (task: Task) => void; onDelete?: (task: Task) => void; onOpenFullPage?: (task: Task) => void; - onMove?: (task: Task, targetColumnId: string) => void; - columns?: WorkflowColumn[]; + onMove?: (task: Task, targetStepId: string) => void; + steps?: WorkflowStep[]; showMaximizeButton?: boolean; isDeleting?: boolean; } @@ -114,7 +116,7 @@ function KanbanCardLayout({ task, repositoryName, className }: KanbanCardProps & ); } -export function KanbanCard({ task, repositoryName, onClick, onEdit, onDelete, onOpenFullPage, onMove, columns, showMaximizeButton = true, isDeleting }: KanbanCardProps) { +export function KanbanCard({ task, repositoryName, onClick, onEdit, onDelete, onOpenFullPage, onMove, steps, showMaximizeButton = true, isDeleting }: KanbanCardProps) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: task.id, }); @@ -139,6 +141,7 @@ export function KanbanCard({ task, repositoryName, onClick, onEdit, onDelete, on className={cn( 'max-h-48 bg-card rounded-sm data-[size=sm]:py-1 cursor-pointer mb-2 w-full py-0 relative border border-border overflow-visible shadow-none ring-0', (task.state === 'IN_PROGRESS' || task.state === 'SCHEDULING') && 'kanban-task-pulse', + needsAction(task) && 'border-l-2 border-l-amber-500', isDragging && 'opacity-50 z-50' )} onClick={() => onClick?.(task)} @@ -193,7 +196,7 @@ export function KanbanCard({ task, repositoryName, onClick, onEdit, onDelete, on > Edit - {columns && columns.length > 1 && onMove && ( + {steps && steps.length > 1 && onMove && ( - {columns + {steps .filter((col) => col.id !== task.workflowStepId) .map((col) => ( }>; on_turn_complete?: Array<{ type: string; config?: Record }> }; } interface KanbanColumnProps { - column: WorkflowStep; + step: WorkflowStep; tasks: Task[]; onPreviewTask: (task: Task) => void; onOpenTask: (task: Task) => void; onEditTask: (task: Task) => void; onDeleteTask: (task: Task) => void; - onMoveTask?: (task: Task, targetColumnId: string) => void; - columns?: WorkflowColumn[]; + onMoveTask?: (task: Task, targetStepId: string) => void; + steps?: WorkflowStep[]; showMaximizeButton?: boolean; deletingTaskId?: string | null; hideHeader?: boolean; } -export function KanbanColumn({ column, tasks, onPreviewTask, onOpenTask, onEditTask, onDeleteTask, onMoveTask, columns, showMaximizeButton, deletingTaskId, hideHeader = false }: KanbanColumnProps) { +export function KanbanColumn({ step, tasks, onPreviewTask, onOpenTask, onEditTask, onDeleteTask, onMoveTask, steps, showMaximizeButton, deletingTaskId, hideHeader = false }: KanbanColumnProps) { const { setNodeRef, isOver } = useDroppable({ - id: column.id, + id: step.id, }); // Access repositories from store to pass repository names to cards @@ -52,16 +52,17 @@ export function KanbanColumn({ column, tasks, onPreviewTask, onOpenTask, onEditT
{/* Column Header */} {!hideHeader && ( -
+
-
-

{column.title}

+
+

{step.title}

{tasks.length} @@ -81,7 +82,7 @@ export function KanbanColumn({ column, tasks, onPreviewTask, onOpenTask, onEditT onEdit={onEditTask} onDelete={onDeleteTask} onMove={onMoveTask} - columns={columns} + steps={steps} showMaximizeButton={showMaximizeButton} isDeleting={deletingTaskId === task.id} /> diff --git a/apps/web/components/kanban-display-dropdown.tsx b/apps/web/components/kanban-display-dropdown.tsx index 802a8c253a..d5495b918d 100644 --- a/apps/web/components/kanban-display-dropdown.tsx +++ b/apps/web/components/kanban-display-dropdown.tsx @@ -19,22 +19,22 @@ import { import { IconAdjustmentsHorizontal } from '@tabler/icons-react'; import { useKanbanDisplaySettings } from '@/hooks/use-kanban-display-settings'; import type { Workspace, Repository } from '@/lib/types/http'; -import type { BoardState } from '@/lib/state/slices'; +import type { WorkflowsState } from '@/lib/state/slices'; import { Badge } from '@kandev/ui/badge'; export function KanbanDisplayDropdown() { const { workspaces, - boards, + workflows, activeWorkspaceId, - activeBoardId, + activeWorkflowId, repositories, repositoriesLoading, allRepositoriesSelected, selectedRepositoryId, enablePreviewOnClick, onWorkspaceChange, - onBoardChange, + onWorkflowChange, onRepositoryChange, onTogglePreviewOnClick, } = useKanbanDisplaySettings(); @@ -52,12 +52,12 @@ export function KanbanDisplayDropdown() {
- Workspace + Workspace onBoardChange(value || null)} + value={activeWorkflowId ?? 'all'} + onValueChange={(value) => onWorkflowChange(value === 'all' ? null : value)} > - - + + - {boards.map((board: BoardState['items'][number]) => ( - - {board.name} + All Workflows + {workflows.map((workflow: WorkflowsState['items'][number]) => ( + + {workflow.name} ))} @@ -90,13 +91,13 @@ export function KanbanDisplayDropdown() {
- Repository + Repository onBoardChange(value || null)} + value={activeWorkflowId ?? ''} + onValueChange={(value) => onWorkflowChange(value || null)} > - + - {boards.map((board: BoardState['items'][number]) => ( - - {board.name} + {workflows.map((workflow: WorkflowsState['items'][number]) => ( + + {workflow.name} ))} diff --git a/apps/web/components/kanban/swimlane-container.tsx b/apps/web/components/kanban/swimlane-container.tsx new file mode 100644 index 0000000000..fa749d35bd --- /dev/null +++ b/apps/web/components/kanban/swimlane-container.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useMemo } from 'react'; +import { useAppStore } from '@/components/state-provider'; +import { useSwimlaneCollapse } from '@/hooks/domains/kanban/use-swimlane-collapse'; +import { filterTasksByRepositories, mapSelectedRepositoryIds } from '@/lib/kanban/filters'; +import { SwimlaneSection } from './swimlane-section'; +import { getViewByStoredValue, getDefaultView } from '@/lib/kanban/view-registry'; +import type { Task } from '@/components/kanban-card'; +import type { WorkflowAutomation, MoveTaskError } from '@/hooks/use-drag-and-drop'; +import type { Repository } from '@/lib/types/http'; + +export type SwimlaneContainerProps = { + viewMode: string; + workflowFilter: string | null; + onPreviewTask: (task: Task) => void; + onOpenTask: (task: Task) => void; + onEditTask: (task: Task) => void; + onDeleteTask: (task: Task) => void; + onMoveError?: (error: MoveTaskError) => void; + onWorkflowAutomation?: (automation: WorkflowAutomation) => void; + deletingTaskId?: string | null; + searchQuery?: string; + selectedRepositoryIds?: string[]; +}; + +export function SwimlaneContainer({ + viewMode, + workflowFilter, + onPreviewTask, + onOpenTask, + onEditTask, + onDeleteTask, + onMoveError, + onWorkflowAutomation, + deletingTaskId, + searchQuery, + selectedRepositoryIds = [], +}: SwimlaneContainerProps) { + const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); + const isLoading = useAppStore((state) => state.kanbanMulti.isLoading); + const workflows = useAppStore((state) => state.workflows.items); + const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const { isCollapsed, toggleCollapse } = useSwimlaneCollapse(); + + const repositories = useMemo( + () => Object.values(repositoriesByWorkspace).flat() as Repository[], + [repositoriesByWorkspace] + ); + + const repoFilter = useMemo( + () => mapSelectedRepositoryIds(repositories, selectedRepositoryIds), + [repositories, selectedRepositoryIds] + ); + + // Build ordered list of workflows to render + const orderedWorkflows = useMemo(() => { + if (workflowFilter) { + // Single workflow mode + const snapshot = snapshots[workflowFilter]; + if (!snapshot) return []; + return [{ id: workflowFilter, name: snapshot.workflowName }]; + } + // All workflows - use workflow order, only show those with snapshots + return workflows.filter((wf) => snapshots[wf.id]); + }, [workflowFilter, workflows, snapshots]); + + // Filter tasks per workflow + const getFilteredTasks = (workflowId: string): Task[] => { + const snapshot = snapshots[workflowId]; + if (!snapshot) return []; + let tasks = snapshot.tasks as Task[]; + + // Apply repository filter + tasks = filterTasksByRepositories(tasks, repoFilter); + + // Apply search filter + if (searchQuery) { + const q = searchQuery.toLowerCase(); + tasks = tasks.filter( + (t) => + t.title.toLowerCase().includes(q) || + (t.description && t.description.toLowerCase().includes(q)) + ); + } + + return tasks; + }; + + if (isLoading && Object.keys(snapshots).length === 0) { + return ( +
+
+ Loading... +
+
+ ); + } + + if (orderedWorkflows.length === 0) { + return ( +
+
+ No workflows available yet. +
+
+ ); + } + + // Filter to only workflows with tasks (unless a specific workflow is selected) + const visibleWorkflows = workflowFilter + ? orderedWorkflows + : orderedWorkflows.filter((wf) => getFilteredTasks(wf.id).length > 0); + + if (visibleWorkflows.length === 0) { + return ( +
+
+ No tasks yet +
+
+ ); + } + + return ( +
+ {visibleWorkflows.map((wf) => { + const snapshot = snapshots[wf.id]; + if (!snapshot) return null; + + const tasks = getFilteredTasks(wf.id); + const steps = [...snapshot.steps].sort((a, b) => a.position - b.position); + + return ( + toggleCollapse(wf.id)} + > + {(() => { + const viewEntry = getViewByStoredValue(viewMode) ?? getDefaultView(); + const ViewComponent = viewEntry.component; + return ( + + ); + })()} + + ); + })} +
+ ); +} diff --git a/apps/web/components/kanban/swimlane-graph-content.tsx b/apps/web/components/kanban/swimlane-graph-content.tsx new file mode 100644 index 0000000000..c5f2781338 --- /dev/null +++ b/apps/web/components/kanban/swimlane-graph-content.tsx @@ -0,0 +1,344 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, + useDroppable, + useDraggable, + type Modifier, +} from '@dnd-kit/core'; +import { cn } from '@kandev/ui/lib/utils'; +import { Badge } from '@kandev/ui/badge'; +import { getTaskStateIcon } from '@/lib/ui/state-icons'; +import { needsAction } from '@/lib/utils/needs-action'; +import { useTaskActions } from '@/hooks/use-task-actions'; +import { useAppStoreApi } from '@/components/state-provider'; +import { getWebSocketClient } from '@/lib/ws/connection'; +import type { Task } from '@/components/kanban-card'; +import type { WorkflowStep } from '@/components/kanban-column'; +import type { WorkflowAutomation, MoveTaskError } from '@/hooks/use-drag-and-drop'; +import type { KanbanState } from '@/lib/state/slices/kanban/types'; + +export type SwimlaneGraphContentProps = { + workflowId: string; + steps: WorkflowStep[]; + tasks: Task[]; + onPreviewTask: (task: Task) => void; + onOpenTask?: (task: Task) => void; + onEditTask?: (task: Task) => void; + onDeleteTask?: (task: Task) => void; + onMoveError?: (error: MoveTaskError) => void; + onWorkflowAutomation?: (automation: WorkflowAutomation) => void; + deletingTaskId?: string | null; +}; + +function DroppableStepZone({ + stepId, + isDragging, + children, +}: { + stepId: string; + isDragging: boolean; + children: React.ReactNode; +}) { + const { setNodeRef, isOver } = useDroppable({ id: stepId }); + return ( +
+ {children} +
+ ); +} + +function DraggableTaskChip({ + task, + onPreviewTask, +}: { + task: Task; + onPreviewTask: (task: Task) => void; +}) { + const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ + id: task.id, + }); + const statusIcon = getTaskStateIcon(task.state, 'h-3 w-3'); + + return ( + + ); +} + +function TaskChipPreview({ task }: { task: Task }) { + const statusIcon = getTaskStateIcon(task.state, 'h-3 w-3'); + return ( +
+
{statusIcon}
+ {task.title} +
+ ); +} + +export function SwimlaneGraphContent({ + workflowId, + steps, + tasks, + onPreviewTask, + onMoveError, + onWorkflowAutomation, +}: SwimlaneGraphContentProps) { + const store = useAppStoreApi(); + const { moveTaskById } = useTaskActions(); + const [activeTaskId, setActiveTaskId] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }) + ); + + const clampVertical: Modifier[] = useMemo( + () => [({ transform }) => ({ ...transform, y: Math.max(-20, Math.min(20, transform.y)) })], + [] + ); + + const tasksByStep = useMemo(() => { + const map: Record = {}; + for (const col of steps) { + map[col.id] = tasks + .filter((t) => t.workflowStepId === col.id) + .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + } + return map; + }, [steps, tasks]); + + // Build set of step IDs that are valid drop targets for each step (adjacent only) + const adjacentSteps = useMemo(() => { + const map: Record> = {}; + for (let i = 0; i < steps.length; i++) { + const adj = new Set(); + if (i > 0) adj.add(steps[i - 1].id); + if (i < steps.length - 1) adj.add(steps[i + 1].id); + map[steps[i].id] = adj; + } + return map; + }, [steps]); + + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveTaskId(event.active.id as string); + }, []); + + const handleDragEnd = useCallback( + async (event: DragEndEvent) => { + const { active, over } = event; + setActiveTaskId(null); + if (!over) return; + + const taskId = active.id as string; + const targetColumnId = over.id as string; + const task = tasks.find((t) => t.id === taskId); + if (!task || task.workflowStepId === targetColumnId) return; + + // Only allow moves to adjacent steps + const allowed = adjacentSteps[task.workflowStepId]; + if (!allowed || !allowed.has(targetColumnId)) return; + + // Optimistic update + const state = store.getState(); + const snapshot = state.kanbanMulti.snapshots[workflowId]; + if (!snapshot) return; + + const targetTasks = snapshot.tasks + .filter((t: KanbanState['tasks'][number]) => t.workflowStepId === targetColumnId && t.id !== taskId) + .sort((a: KanbanState['tasks'][number], b: KanbanState['tasks'][number]) => a.position - b.position); + const nextPosition = targetTasks.length; + + const originalTasks = snapshot.tasks; + + state.setWorkflowSnapshot(workflowId, { + ...snapshot, + tasks: snapshot.tasks.map((t: KanbanState['tasks'][number]) => + t.id === taskId + ? { ...t, workflowStepId: targetColumnId, position: nextPosition } + : t + ), + }); + + try { + const response = await moveTaskById(taskId, { + workflow_id: workflowId, + workflow_step_id: targetColumnId, + position: nextPosition, + }); + + if (response?.workflow_step?.events?.on_enter?.some((a: { type: string }) => a.type === 'auto_start_agent')) { + const sessionId = task.primarySessionId ?? null; + if (sessionId) { + const client = getWebSocketClient(); + if (client) { + try { + await client.request( + 'orchestrator.start', + { + task_id: task.id, + session_id: sessionId, + workflow_step_id: response.workflow_step.id, + }, + 15000 + ); + } catch (err) { + console.error('Failed to auto-start session for workflow step:', err); + } + } + } else { + onWorkflowAutomation?.({ + taskId: task.id, + sessionId: null, + workflowStep: response.workflow_step, + taskDescription: task.description ?? '', + }); + } + } + } catch (error) { + const currentSnapshot = store.getState().kanbanMulti.snapshots[workflowId]; + if (currentSnapshot) { + store.getState().setWorkflowSnapshot(workflowId, { + ...currentSnapshot, + tasks: originalTasks, + }); + } + const message = error instanceof Error ? error.message : 'Failed to move task'; + onMoveError?.({ message, taskId, sessionId: task.primarySessionId ?? null }); + } + }, + [tasks, workflowId, store, moveTaskById, adjacentSteps, onWorkflowAutomation, onMoveError] + ); + + const handleDragCancel = useCallback(() => { + setActiveTaskId(null); + }, []); + + const activeTask = useMemo( + () => tasks.find((t) => t.id === activeTaskId) ?? null, + [tasks, activeTaskId] + ); + + if (tasks.length === 0) { + return ( +
+
No tasks
+
+ ); + } + + return ( + +
+
+ {steps.map((step, index) => { + const stepTasks = tasksByStep[step.id] ?? []; + const hasActiveTasks = stepTasks.length > 0; + + return ( +
+ {/* Step node + tasks column */} + +
+ {/* Node header */} +
+
+ {step.title} + + {stepTasks.length} + +
+
+ + {/* Task chips under the node */} + {stepTasks.length > 0 && ( +
+ {stepTasks.map((task) => ( + + ))} +
+ )} +
+
+ + {/* Connector arrow — vertically centered to the node header */} + {index < steps.length - 1 && ( +
+
+
+
+ )} +
+ ); + })} +
+
+ + {activeTask ? : null} + + + ); +} diff --git a/apps/web/components/kanban/swimlane-graph2-content.tsx b/apps/web/components/kanban/swimlane-graph2-content.tsx new file mode 100644 index 0000000000..7ab3cf5116 --- /dev/null +++ b/apps/web/components/kanban/swimlane-graph2-content.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useSwimlaneMove } from '@/hooks/domains/kanban/use-swimlane-move'; +import { Graph2TaskPipeline } from './graph2-task-pipeline'; +import type { ViewContentProps } from '@/lib/kanban/view-registry'; + +export function SwimlaneGraph2Content({ + workflowId, + steps, + tasks, + onPreviewTask, + onEditTask, + onDeleteTask, + onMoveError, + onWorkflowAutomation, + deletingTaskId, +}: ViewContentProps) { + const { moveTask } = useSwimlaneMove(workflowId, { + onMoveError, + onWorkflowAutomation, + }); + const [movingTaskId, setMovingTaskId] = useState(null); + + const sortedTasks = useMemo( + () => + [...tasks].sort((a, b) => { + const aStepIdx = steps.findIndex((c) => c.id === a.workflowStepId); + const bStepIdx = steps.findIndex((c) => c.id === b.workflowStepId); + if (aStepIdx !== bStepIdx) return aStepIdx - bStepIdx; + return (a.position ?? 0) - (b.position ?? 0); + }), + [tasks, steps] + ); + + const handleMoveTask = async (task: typeof tasks[number], targetStepId: string) => { + setMovingTaskId(task.id); + try { + await moveTask(task, targetStepId); + } finally { + setMovingTaskId(null); + } + }; + + if (tasks.length === 0) { + return ( +
+
No tasks
+
+ ); + } + + return ( +
+
+ {sortedTasks.map((task) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/components/kanban/swimlane-header.tsx b/apps/web/components/kanban/swimlane-header.tsx new file mode 100644 index 0000000000..92d1376e50 --- /dev/null +++ b/apps/web/components/kanban/swimlane-header.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { Badge } from '@kandev/ui/badge'; +import { IconChevronRight } from '@tabler/icons-react'; +import { cn } from '@kandev/ui/lib/utils'; + +export type SwimlaneHeaderProps = { + workflowName: string; + taskCount: number; + isCollapsed: boolean; + onToggleCollapse: () => void; +}; + +export function SwimlaneHeader({ + workflowName, + taskCount, + isCollapsed, + onToggleCollapse, +}: SwimlaneHeaderProps) { + return ( + + ); +} diff --git a/apps/web/components/kanban/swimlane-kanban-content.tsx b/apps/web/components/kanban/swimlane-kanban-content.tsx new file mode 100644 index 0000000000..03eebc8d90 --- /dev/null +++ b/apps/web/components/kanban/swimlane-kanban-content.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { KanbanColumn } from '@/components/kanban-column'; +import { KanbanCardPreview, type Task } from '@/components/kanban-card'; +import type { WorkflowStep } from '@/components/kanban-column'; +import type { WorkflowAutomation, MoveTaskError } from '@/hooks/use-drag-and-drop'; +import { useTaskActions } from '@/hooks/use-task-actions'; +import { useAppStoreApi } from '@/components/state-provider'; +import { getWebSocketClient } from '@/lib/ws/connection'; +import type { KanbanState } from '@/lib/state/slices/kanban/types'; + +export type SwimlaneKanbanContentProps = { + workflowId: string; + steps: WorkflowStep[]; + tasks: Task[]; + onPreviewTask: (task: Task) => void; + onOpenTask: (task: Task) => void; + onEditTask: (task: Task) => void; + onDeleteTask: (task: Task) => void; + onMoveError?: (error: MoveTaskError) => void; + onWorkflowAutomation?: (automation: WorkflowAutomation) => void; + deletingTaskId?: string | null; +}; + +export function SwimlaneKanbanContent({ + workflowId, + steps, + tasks, + onPreviewTask, + onOpenTask, + onEditTask, + onDeleteTask, + onMoveError, + onWorkflowAutomation, + deletingTaskId, +}: SwimlaneKanbanContentProps) { + const store = useAppStoreApi(); + const { moveTaskById } = useTaskActions(); + const [activeTaskId, setActiveTaskId] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }) + ); + + const getTasksForStep = (stepId: string) => { + const colTasks = tasks.filter((t) => t.workflowStepId === stepId); + return colTasks.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + }; + + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveTaskId(event.active.id as string); + }, []); + + const handleDragEnd = useCallback( + async (event: DragEndEvent) => { + const { active, over } = event; + setActiveTaskId(null); + if (!over) return; + + const taskId = active.id as string; + const targetStepId = over.id as string; + const task = tasks.find((t) => t.id === taskId); + if (!task || task.workflowStepId === targetStepId) return; + + // Optimistic update on the multi snapshot + const state = store.getState(); + const snapshot = state.kanbanMulti.snapshots[workflowId]; + if (!snapshot) return; + + const targetTasks = snapshot.tasks + .filter((t: KanbanState['tasks'][number]) => t.workflowStepId === targetStepId && t.id !== taskId) + .sort((a: KanbanState['tasks'][number], b: KanbanState['tasks'][number]) => a.position - b.position); + const nextPosition = targetTasks.length; + + // Save for rollback + const originalTasks = snapshot.tasks; + + state.setWorkflowSnapshot(workflowId, { + ...snapshot, + tasks: snapshot.tasks.map((t: KanbanState['tasks'][number]) => + t.id === taskId + ? { ...t, workflowStepId: targetStepId, position: nextPosition } + : t + ), + }); + + try { + const response = await moveTaskById(taskId, { + workflow_id: workflowId, + workflow_step_id: targetStepId, + position: nextPosition, + }); + + // Handle workflow automation + if (response?.workflow_step?.events?.on_enter?.some((a: { type: string }) => a.type === 'auto_start_agent')) { + const sessionId = task.primarySessionId ?? null; + if (sessionId) { + const client = getWebSocketClient(); + if (client) { + try { + await client.request( + 'orchestrator.start', + { + task_id: task.id, + session_id: sessionId, + workflow_step_id: response.workflow_step.id, + }, + 15000 + ); + } catch (err) { + console.error('Failed to auto-start session for workflow step:', err); + } + } + } else { + onWorkflowAutomation?.({ + taskId: task.id, + sessionId: null, + workflowStep: response.workflow_step, + taskDescription: task.description ?? '', + }); + } + } + } catch (error) { + // Rollback + const currentSnapshot = store.getState().kanbanMulti.snapshots[workflowId]; + if (currentSnapshot) { + store.getState().setWorkflowSnapshot(workflowId, { + ...currentSnapshot, + tasks: originalTasks, + }); + } + const message = error instanceof Error ? error.message : 'Failed to move task'; + onMoveError?.({ message, taskId, sessionId: task.primarySessionId ?? null }); + } + }, + [tasks, workflowId, store, moveTaskById, onWorkflowAutomation, onMoveError] + ); + + const handleDragCancel = useCallback(() => { + setActiveTaskId(null); + }, []); + + const moveTaskToStep = useCallback( + async (task: Task, targetStepId: string) => { + if (task.workflowStepId === targetStepId) return; + // Reuse drag end logic + const fakeEvent = { + active: { id: task.id }, + over: { id: targetStepId }, + } as DragEndEvent; + await handleDragEnd(fakeEvent); + }, + [handleDragEnd] + ); + + const activeTask = useMemo( + () => tasks.find((t) => t.id === activeTaskId) ?? null, + [tasks, activeTaskId] + ); + + if (steps.length === 0) return null; + + return ( + +
+
+ {steps.map((step) => ( + + ))} +
+
+ + {activeTask ? : null} + +
+ ); +} diff --git a/apps/web/components/kanban/swimlane-section.tsx b/apps/web/components/kanban/swimlane-section.tsx new file mode 100644 index 0000000000..2122213f97 --- /dev/null +++ b/apps/web/components/kanban/swimlane-section.tsx @@ -0,0 +1,33 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { SwimlaneHeader } from './swimlane-header'; + +export type SwimlaneSectionProps = { + workflowId: string; + workflowName: string; + taskCount: number; + isCollapsed: boolean; + onToggleCollapse: () => void; + children: ReactNode; +}; + +export function SwimlaneSection({ + workflowName, + taskCount, + isCollapsed, + onToggleCollapse, + children, +}: SwimlaneSectionProps) { + return ( +
+ + {!isCollapsed && children} +
+ ); +} diff --git a/apps/web/components/kanban/swipeable-columns.tsx b/apps/web/components/kanban/swipeable-columns.tsx index 41a1dbc8b0..53ffb6a169 100644 --- a/apps/web/components/kanban/swipeable-columns.tsx +++ b/apps/web/components/kanban/swipeable-columns.tsx @@ -6,7 +6,7 @@ import { KanbanColumn, WorkflowStep } from '../kanban-column'; import { Task } from '../kanban-card'; type SwipeableColumnsProps = { - columns: WorkflowStep[]; + steps: WorkflowStep[]; tasks: Task[]; activeIndex: number; onIndexChange: (index: number) => void; @@ -14,13 +14,13 @@ type SwipeableColumnsProps = { onOpenTask: (task: Task) => void; onEditTask: (task: Task) => void; onDeleteTask: (task: Task) => void; - onMoveTask?: (task: Task, targetColumnId: string) => void; + onMoveTask?: (task: Task, targetStepId: string) => void; showMaximizeButton?: boolean; deletingTaskId?: string | null; }; export function SwipeableColumns({ - columns, + steps, tasks, activeIndex, onIndexChange, @@ -38,10 +38,10 @@ export function SwipeableColumns({ watchDrag: true, }); - const getTasksForColumn = useCallback( - (columnId: string) => { + const getTasksForStep = useCallback( + (stepId: string) => { return tasks - .filter((task) => task.workflowStepId === columnId) + .filter((task) => task.workflowStepId === stepId) .map((task) => ({ ...task, position: task.position ?? 0 })) .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); }, @@ -81,20 +81,20 @@ export function SwipeableColumns({ style={{ height: 'calc(100dvh - 100px - env(safe-area-inset-bottom, 0px))' }} >
- {columns.map((column) => ( + {steps.map((step) => (
-

Board Columns

+

Workflow Steps

- Customize your columns anytime to match your workflow. + Customize your workflow steps anytime to match your process.

diff --git a/apps/web/components/settings/board-card.tsx b/apps/web/components/settings/board-card.tsx deleted file mode 100644 index 8f1b334c1e..0000000000 --- a/apps/web/components/settings/board-card.tsx +++ /dev/null @@ -1,274 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { IconTrash } from '@tabler/icons-react'; -import { Card, CardContent } from '@kandev/ui/card'; -import { Button } from '@kandev/ui/button'; -import { Input } from '@kandev/ui/input'; -import { Label } from '@kandev/ui/label'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@kandev/ui/dialog'; -import type { Board, WorkflowStep } from '@/lib/types/http'; -import { useRequest } from '@/lib/http/use-request'; -import { useToast } from '@/components/toast-provider'; -import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator'; -import { WorkflowStepEditor } from '@/components/settings/workflow-step-editor'; -import { - listWorkflowStepsAction, - createWorkflowStepAction, - updateWorkflowStepAction, - deleteWorkflowStepAction, - reorderWorkflowStepsAction, -} from '@/app/actions/workspaces'; - -type BoardCardProps = { - board: Board; - isBoardDirty: boolean; - initialWorkflowSteps?: WorkflowStep[]; - onUpdateBoard: (updates: { name?: string; description?: string }) => void; - onDeleteBoard: () => Promise; - onSaveBoard: () => Promise; -}; - -export function BoardCard({ - board, - isBoardDirty, - initialWorkflowSteps, - onUpdateBoard, - onDeleteBoard, - onSaveBoard, -}: BoardCardProps) { - const { toast } = useToast(); - const [deleteOpen, setDeleteOpen] = useState(false); - - const saveBoardRequest = useRequest(onSaveBoard); - const deleteBoardRequest = useRequest(onDeleteBoard); - - // Workflow state - const [workflowSteps, setWorkflowSteps] = useState(initialWorkflowSteps ?? []); - const [workflowLoading, setWorkflowLoading] = useState(false); - - const isNewBoard = board.id.startsWith('temp-'); - - // Load workflow steps on mount (only for saved boards) - useEffect(() => { - if (isNewBoard) { - setWorkflowSteps(initialWorkflowSteps ?? []); - setWorkflowLoading(false); - return; - } - - let cancelled = false; - const load = async () => { - setWorkflowLoading(true); - try { - const res = await listWorkflowStepsAction(board.id); - if (!cancelled) { - setWorkflowSteps(res.steps ?? []); - } - } catch { - if (!cancelled) { - toast({ - title: 'Failed to load workflow steps', - variant: 'error', - }); - } - } finally { - if (!cancelled) { - setWorkflowLoading(false); - } - } - }; - load(); - return () => { - cancelled = true; - }; - }, [board.id, initialWorkflowSteps, isNewBoard, toast]); - - const refreshWorkflowSteps = async () => { - try { - const res = await listWorkflowStepsAction(board.id); - setWorkflowSteps(res.steps ?? []); - } catch { - // Ignore errors on refresh - } - }; - - const handleUpdateWorkflowStep = async (stepId: string, updates: Partial) => { - if (isNewBoard) return; - try { - await updateWorkflowStepAction(stepId, updates); - await refreshWorkflowSteps(); - } catch (error) { - toast({ - title: 'Failed to update workflow step', - description: error instanceof Error ? error.message : 'Request failed', - variant: 'error', - }); - } - }; - - const handleAddWorkflowStep = async () => { - if (isNewBoard) return; - try { - await createWorkflowStepAction({ - board_id: board.id, - name: 'New Step', - step_type: 'implementation', - position: workflowSteps.length, - color: 'bg-slate-500', - }); - await refreshWorkflowSteps(); - } catch (error) { - toast({ - title: 'Failed to add workflow step', - description: error instanceof Error ? error.message : 'Request failed', - variant: 'error', - }); - } - }; - - const handleRemoveWorkflowStep = async (stepId: string) => { - if (isNewBoard) return; - try { - await deleteWorkflowStepAction(stepId); - await refreshWorkflowSteps(); - } catch (error) { - toast({ - title: 'Failed to delete workflow step', - description: error instanceof Error ? error.message : 'Request failed', - variant: 'error', - }); - } - }; - - const handleReorderWorkflowSteps = async (reorderedSteps: WorkflowStep[]) => { - if (isNewBoard) return; - // Optimistically update the UI - setWorkflowSteps(reorderedSteps); - try { - const stepIds = reorderedSteps.map((step) => step.id); - await reorderWorkflowStepsAction(board.id, stepIds); - } catch (error) { - toast({ - title: 'Failed to reorder workflow steps', - description: error instanceof Error ? error.message : 'Request failed', - variant: 'error', - }); - // Refresh to get the actual order - await refreshWorkflowSteps(); - } - }; - - const handleDeleteBoard = async () => { - try { - await deleteBoardRequest.run(); - setDeleteOpen(false); - } catch (error) { - toast({ - title: 'Failed to delete board', - description: error instanceof Error ? error.message : 'Request failed', - variant: 'error', - }); - } - }; - - const handleSaveBoard = async () => { - try { - await saveBoardRequest.run(); - } catch (error) { - toast({ - title: 'Failed to save board changes', - description: error instanceof Error ? error.message : 'Request failed', - variant: 'error', - }); - } - }; - - return ( - - -
-
-
- -
- onUpdateBoard({ name: e.target.value })} - /> - -
-
-
- - {/* Workflow Steps Section - only show for saved boards */} -
- - {isNewBoard && ( -

- Steps will be created when you save the board. -

- )} - {workflowLoading ? ( -
Loading workflow steps...
- ) : ( - - )} -
- -
- -
-
-
- - - - Delete board - - This will remove the board and its workflow steps. Tasks will not be deleted. This action cannot be undone. - - - - - - - - -
- ); -} diff --git a/apps/web/components/settings/editors-settings.tsx b/apps/web/components/settings/editors-settings.tsx index f086382260..0453c5a311 100644 --- a/apps/web/components/settings/editors-settings.tsx +++ b/apps/web/components/settings/editors-settings.tsx @@ -477,7 +477,6 @@ export function EditorsSettings() { if (parsedConfigs === null) return; // Invalid JSON, don't save const payload: Parameters[0] = { workspace_id: currentUserSettings.workspaceId ?? '', - board_id: currentUserSettings.boardId ?? '', repository_ids: currentUserSettings.repositoryIds ?? [], default_editor_id: defaultEditorId || undefined, lsp_auto_start_languages: lspAutoStartLanguages, @@ -492,7 +491,8 @@ export function EditorsSettings() { if (response?.settings) { setUserSettings({ workspaceId: response.settings.workspace_id || null, - boardId: response.settings.board_id || null, + workflowId: response.settings.workflow_filter_id || null, + kanbanViewMode: response.settings.kanban_view_mode || null, repositoryIds: response.settings.repository_ids ?? [], preferredShell: response.settings.preferred_shell || null, shellOptions: currentUserSettings.shellOptions ?? [], diff --git a/apps/web/components/settings/general-settings.tsx b/apps/web/components/settings/general-settings.tsx index 13f55c0f2a..1fffd9e1c7 100644 --- a/apps/web/components/settings/general-settings.tsx +++ b/apps/web/components/settings/general-settings.tsx @@ -40,7 +40,6 @@ export function GeneralSettings() { await updateUserSettings({ workspace_id: userSettings.workspaceId || '', - board_id: userSettings.boardId || '', repository_ids: userSettings.repositoryIds || [], chat_submit_key: value, }); diff --git a/apps/web/components/settings/settings-app-sidebar.tsx b/apps/web/components/settings/settings-app-sidebar.tsx index 7489f4303f..6321b67e87 100644 --- a/apps/web/components/settings/settings-app-sidebar.tsx +++ b/apps/web/components/settings/settings-app-sidebar.tsx @@ -120,7 +120,7 @@ export function SettingsAppSidebar() { {workspaces.map((workspace: Workspace) => { const workspacePath = `/settings/workspace/${workspace.id}`; - const boardsPath = `${workspacePath}/boards`; + const workflowsPath = `${workspacePath}/workflows`; const repositoriesPath = `${workspacePath}/repositories`; return ( @@ -149,10 +149,10 @@ export function SettingsAppSidebar() { - - Boards + + Workflows diff --git a/apps/web/components/settings/shell-settings-card.tsx b/apps/web/components/settings/shell-settings-card.tsx index b4ef0d7140..726bab6921 100644 --- a/apps/web/components/settings/shell-settings-card.tsx +++ b/apps/web/components/settings/shell-settings-card.tsx @@ -46,7 +46,6 @@ export function ShellSettingsCard() { const currentSettings = storeApi.getState().userSettings; await updateUserSettings({ workspace_id: currentSettings.workspaceId ?? '', - board_id: currentSettings.boardId ?? '', repository_ids: currentSettings.repositoryIds, preferred_shell: trimmed, }); diff --git a/apps/web/components/settings/workflow-card.tsx b/apps/web/components/settings/workflow-card.tsx new file mode 100644 index 0000000000..0fec585ec9 --- /dev/null +++ b/apps/web/components/settings/workflow-card.tsx @@ -0,0 +1,600 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { IconTrash } from '@tabler/icons-react'; +import { Card, CardContent } from '@kandev/ui/card'; +import { Button } from '@kandev/ui/button'; +import { Input } from '@kandev/ui/input'; +import { Label } from '@kandev/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@kandev/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@kandev/ui/select'; +import type { Workflow, WorkflowStep } from '@/lib/types/http'; +import { useRequest } from '@/lib/http/use-request'; +import { useToast } from '@/components/toast-provider'; +import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator'; +import { WorkflowPipelineEditor } from '@/components/settings/workflow-pipeline-editor'; +import { generateUUID } from '@/lib/utils'; +import { + createWorkflowAction, + listWorkflowStepsAction, + createWorkflowStepAction, + updateWorkflowStepAction, + deleteWorkflowStepAction, + reorderWorkflowStepsAction, + getWorkflowTaskCount, + getStepTaskCount, + bulkMoveTasks, +} from '@/app/actions/workspaces'; + +type WorkflowCardProps = { + workflow: Workflow; + isWorkflowDirty: boolean; + initialWorkflowSteps?: WorkflowStep[]; + otherWorkflows?: Workflow[]; + onUpdateWorkflow: (updates: { name?: string; description?: string }) => void; + onDeleteWorkflow: () => Promise; + onSaveWorkflow: () => Promise; + onWorkflowCreated?: (created: Workflow) => void; +}; + +export function WorkflowCard({ + workflow, + isWorkflowDirty, + initialWorkflowSteps, + otherWorkflows = [], + onUpdateWorkflow, + onDeleteWorkflow, + onSaveWorkflow, + onWorkflowCreated, +}: WorkflowCardProps) { + const { toast } = useToast(); + const [deleteOpen, setDeleteOpen] = useState(false); + + // Workflow deletion migration state + const [workflowTaskCount, setWorkflowTaskCount] = useState(null); + const [workflowDeleteLoading, setWorkflowDeleteLoading] = useState(false); + const [targetWorkflowId, setTargetWorkflowId] = useState(''); + const [targetWorkflowSteps, setTargetWorkflowSteps] = useState([]); + const [targetStepId, setTargetStepId] = useState(''); + const [migrateLoading, setMigrateLoading] = useState(false); + + // Step deletion migration state + const [stepDeleteOpen, setStepDeleteOpen] = useState(false); + const [stepToDelete, setStepToDelete] = useState(null); + const [stepTaskCount, setStepTaskCount] = useState(null); + const [targetStepForMigration, setTargetStepForMigration] = useState(''); + const [stepMigrateLoading, setStepMigrateLoading] = useState(false); + + const saveWorkflowRequest = useRequest(onSaveWorkflow); + const deleteWorkflowRequest = useRequest(onDeleteWorkflow); + + // Workflow state + const [workflowSteps, setWorkflowSteps] = useState(initialWorkflowSteps ?? []); + const [workflowLoading, setWorkflowLoading] = useState(false); + + const isNewWorkflow = workflow.id.startsWith('temp-'); + + // Load workflow steps on mount (only for saved workflows) + useEffect(() => { + if (isNewWorkflow) { + setWorkflowSteps(initialWorkflowSteps ?? []); + setWorkflowLoading(false); + return; + } + + let cancelled = false; + const load = async () => { + setWorkflowLoading(true); + try { + const res = await listWorkflowStepsAction(workflow.id); + if (!cancelled) { + setWorkflowSteps(res.steps ?? []); + } + } catch { + if (!cancelled) { + toast({ + title: 'Failed to load workflow steps', + variant: 'error', + }); + } + } finally { + if (!cancelled) { + setWorkflowLoading(false); + } + } + }; + load(); + return () => { + cancelled = true; + }; + }, [workflow.id, initialWorkflowSteps, isNewWorkflow, toast]); + + // Load target workflow steps when target workflow changes + useEffect(() => { + if (!targetWorkflowId) { + setTargetWorkflowSteps([]); + setTargetStepId(''); + return; + } + let cancelled = false; + listWorkflowStepsAction(targetWorkflowId).then((res) => { + if (!cancelled) { + const steps = res.steps ?? []; + setTargetWorkflowSteps(steps); + setTargetStepId(steps.length > 0 ? steps[0].id : ''); + } + }).catch(() => { + if (!cancelled) setTargetWorkflowSteps([]); + }); + return () => { cancelled = true; }; + }, [targetWorkflowId]); + + const refreshWorkflowSteps = async () => { + try { + const res = await listWorkflowStepsAction(workflow.id); + setWorkflowSteps(res.steps ?? []); + } catch { + // Ignore errors on refresh + } + }; + + const handleUpdateWorkflowStep = async (stepId: string, updates: Partial) => { + if (isNewWorkflow) { + setWorkflowSteps((prev) => + prev.map((s) => (s.id === stepId ? { ...s, ...updates } : s)) + ); + return; + } + try { + await updateWorkflowStepAction(stepId, updates); + await refreshWorkflowSteps(); + } catch (error) { + toast({ + title: 'Failed to update workflow step', + description: error instanceof Error ? error.message : 'Request failed', + variant: 'error', + }); + } + }; + + const handleAddWorkflowStep = async () => { + if (isNewWorkflow) { + setWorkflowSteps((prev) => [ + ...prev, + { + id: `temp-step-${generateUUID()}`, + workflow_id: workflow.id, + name: 'New Step', + position: prev.length, + color: 'bg-slate-500', + allow_manual_move: true, + created_at: '', + updated_at: '', + }, + ]); + return; + } + try { + await createWorkflowStepAction({ + workflow_id: workflow.id, + name: 'New Step', + position: workflowSteps.length, + color: 'bg-slate-500', + }); + await refreshWorkflowSteps(); + } catch (error) { + toast({ + title: 'Failed to add workflow step', + description: error instanceof Error ? error.message : 'Request failed', + variant: 'error', + }); + } + }; + + const handleRemoveWorkflowStep = async (stepId: string) => { + if (isNewWorkflow) { + setWorkflowSteps((prev) => + prev.filter((s) => s.id !== stepId).map((s, i) => ({ ...s, position: i })) + ); + return; + } + try { + const { task_count } = await getStepTaskCount(stepId); + if (task_count === 0) { + await deleteWorkflowStepAction(stepId); + await refreshWorkflowSteps(); + return; + } + // Has tasks — show migration dialog + setStepToDelete(stepId); + setStepTaskCount(task_count); + const otherSteps = workflowSteps.filter((s) => s.id !== stepId); + setTargetStepForMigration(otherSteps.length > 0 ? otherSteps[0].id : ''); + setStepDeleteOpen(true); + } catch (error) { + toast({ + title: 'Failed to check step tasks', + description: error instanceof Error ? error.message : 'Request failed', + variant: 'error', + }); + } + }; + + const handleReorderWorkflowSteps = async (reorderedSteps: WorkflowStep[]) => { + if (isNewWorkflow) { + setWorkflowSteps(reorderedSteps); + return; + } + // Optimistically update the UI + setWorkflowSteps(reorderedSteps); + try { + const stepIds = reorderedSteps.map((step) => step.id); + await reorderWorkflowStepsAction(workflow.id, stepIds); + } catch (error) { + toast({ + title: 'Failed to reorder workflow steps', + description: error instanceof Error ? error.message : 'Request failed', + variant: 'error', + }); + // Refresh to get the actual order + await refreshWorkflowSteps(); + } + }; + + // Workflow deletion: check task count, then show appropriate dialog + const handleDeleteWorkflowClick = async () => { + if (isNewWorkflow) { + setWorkflowTaskCount(0); + setDeleteOpen(true); + return; + } + setWorkflowDeleteLoading(true); + try { + const { task_count } = await getWorkflowTaskCount(workflow.id); + setWorkflowTaskCount(task_count); + if (task_count > 0 && otherWorkflows.length > 0) { + setTargetWorkflowId(otherWorkflows[0].id); + } + setDeleteOpen(true); + } catch (error) { + toast({ + title: 'Failed to check workflow tasks', + description: error instanceof Error ? error.message : 'Request failed', + variant: 'error', + }); + } finally { + setWorkflowDeleteLoading(false); + } + }; + + const handleDeleteWorkflow = async () => { + try { + await deleteWorkflowRequest.run(); + setDeleteOpen(false); + } catch (error) { + toast({ + title: 'Failed to delete workflow', + description: error instanceof Error ? error.message : 'Request failed', + variant: 'error', + }); + } + }; + + const handleMigrateAndDeleteWorkflow = async () => { + if (!targetWorkflowId || !targetStepId) return; + setMigrateLoading(true); + try { + await bulkMoveTasks({ + source_workflow_id: workflow.id, + target_workflow_id: targetWorkflowId, + target_step_id: targetStepId, + }); + await deleteWorkflowRequest.run(); + setDeleteOpen(false); + } catch (error) { + toast({ + title: 'Failed to migrate tasks', + description: error instanceof Error ? error.message : 'Request failed', + variant: 'error', + }); + } finally { + setMigrateLoading(false); + } + }; + + const handleMigrateAndDeleteStep = async () => { + if (!stepToDelete || !targetStepForMigration) return; + setStepMigrateLoading(true); + try { + await bulkMoveTasks({ + source_workflow_id: workflow.id, + source_step_id: stepToDelete, + target_workflow_id: workflow.id, + target_step_id: targetStepForMigration, + }); + await deleteWorkflowStepAction(stepToDelete); + await refreshWorkflowSteps(); + setStepDeleteOpen(false); + setStepToDelete(null); + } catch (error) { + toast({ + title: 'Failed to migrate tasks', + description: error instanceof Error ? error.message : 'Request failed', + variant: 'error', + }); + } finally { + setStepMigrateLoading(false); + } + }; + + const handleDeleteStepAndTasks = async () => { + if (!stepToDelete) return; + setStepMigrateLoading(true); + try { + await deleteWorkflowStepAction(stepToDelete); + await refreshWorkflowSteps(); + setStepDeleteOpen(false); + setStepToDelete(null); + } catch (error) { + toast({ + title: 'Failed to delete step', + description: error instanceof Error ? error.message : 'Request failed', + variant: 'error', + }); + } finally { + setStepMigrateLoading(false); + } + }; + + const handleSaveNewWorkflow = async () => { + const name = workflow.name.trim() || 'New Workflow'; + const created = await createWorkflowAction({ + workspace_id: workflow.workspace_id, + name, + }); + // Create each step with the user's customizations + for (const step of workflowSteps) { + await createWorkflowStepAction({ + workflow_id: created.id, + name: step.name, + position: step.position, + color: step.color, + prompt: step.prompt, + events: step.events, + is_start_step: step.is_start_step, + allow_manual_move: step.allow_manual_move, + }); + } + onWorkflowCreated?.(created); + }; + + const saveNewWorkflowRequest = useRequest(handleSaveNewWorkflow); + + const handleSaveWorkflow = async () => { + try { + if (isNewWorkflow) { + await saveNewWorkflowRequest.run(); + } else { + await saveWorkflowRequest.run(); + } + } catch (error) { + toast({ + title: 'Failed to save workflow changes', + description: error instanceof Error ? error.message : 'Request failed', + variant: 'error', + }); + } + }; + + const activeSaveRequest = isNewWorkflow ? saveNewWorkflowRequest : saveWorkflowRequest; + + const hasTasks = workflowTaskCount !== null && workflowTaskCount > 0; + const stepsForStepMigration = stepToDelete + ? workflowSteps.filter((s) => s.id !== stepToDelete) + : []; + + return ( + + +
+
+
+ +
+ onUpdateWorkflow({ name: e.target.value })} + /> + +
+
+
+ + {/* Workflow Steps Section */} +
+ + {workflowLoading ? ( +
Loading workflow steps...
+ ) : ( + + )} +
+ +
+ +
+
+
+ + {/* Workflow Delete Dialog */} + + + + Delete workflow + + {hasTasks + ? `This workflow has ${workflowTaskCount} task${workflowTaskCount === 1 ? '' : 's'}. Choose where to migrate them, or delete everything.` + : 'This will permanently delete the workflow and all its steps.'} + + + + {hasTasks && otherWorkflows.length > 0 && ( +
+
+ + +
+ {targetWorkflowSteps.length > 0 && ( +
+ + +
+ )} +
+ )} + + + + {hasTasks && otherWorkflows.length > 0 && ( + + )} + + +
+
+ + {/* Step Delete Migration Dialog */} + + + + Delete step + + This step has {stepTaskCount} task{stepTaskCount === 1 ? '' : 's'}. + {stepsForStepMigration.length > 0 + ? ' Choose where to migrate them, or delete the step and its tasks.' + : ' Deleting this step will affect these tasks.'} + + + + {stepsForStepMigration.length > 0 && ( +
+ + +
+ )} + + + + {stepsForStepMigration.length > 0 && ( + + )} + + +
+
+
+ ); +} diff --git a/apps/web/components/settings/workflow-pipeline-editor.tsx b/apps/web/components/settings/workflow-pipeline-editor.tsx new file mode 100644 index 0000000000..2a78198ab1 --- /dev/null +++ b/apps/web/components/settings/workflow-pipeline-editor.tsx @@ -0,0 +1,846 @@ +'use client'; + +import { useMemo, useState, useSyncExternalStore } from 'react'; +import { + DndContext, + closestCenter, + type DragEndEvent, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + horizontalListSortingStrategy, + arrayMove, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { + IconGripVertical, + IconPlus, + IconTrash, + IconRobot, + IconClipboard, + IconArrowRight, + IconChevronRight, + IconInfoCircle, + IconRosetteNumber1, + IconMessageForward, +} from '@tabler/icons-react'; +import { Button } from '@kandev/ui/button'; +import { Input } from '@kandev/ui/input'; +import { Textarea } from '@kandev/ui/textarea'; +import { Checkbox } from '@kandev/ui/checkbox'; +import { Label } from '@kandev/ui/label'; +import { ScrollArea, ScrollBar } from '@kandev/ui/scroll-area'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@kandev/ui/select'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@kandev/ui/tooltip'; +import type { WorkflowStep, OnEnterAction, OnTurnStartAction, OnTurnCompleteAction } from '@/lib/types/http'; +import { useDebouncedCallback } from '@/hooks/use-debounce'; +import { cn } from '@/lib/utils'; + +const STEP_COLORS = [ + { value: 'bg-slate-500', label: 'Gray' }, + { value: 'bg-red-500', label: 'Red' }, + { value: 'bg-orange-500', label: 'Orange' }, + { value: 'bg-yellow-500', label: 'Yellow' }, + { value: 'bg-green-500', label: 'Green' }, + { value: 'bg-cyan-500', label: 'Cyan' }, + { value: 'bg-blue-500', label: 'Blue' }, + { value: 'bg-indigo-500', label: 'Indigo' }, + { value: 'bg-purple-500', label: 'Purple' }, +]; + +type WorkflowPipelineEditorProps = { + steps: WorkflowStep[]; + onUpdateStep: (stepId: string, updates: Partial) => void; + onAddStep: () => void; + onRemoveStep: (stepId: string) => void; + onReorderSteps: (steps: WorkflowStep[]) => void; + readOnly?: boolean; +}; + +// --- Helpers --- + +function hasOnEnterAction(step: WorkflowStep, type: string): boolean { + return step.events?.on_enter?.some((a) => a.type === type) ?? false; +} + +function getTransitionType(step: WorkflowStep): string { + const action = step.events?.on_turn_complete?.find((a) => + ['move_to_next', 'move_to_previous', 'move_to_step'].includes(a.type) + ); + return action?.type ?? 'none'; +} + +function getOnTurnStartTransitionType(step: WorkflowStep): string { + const action = step.events?.on_turn_start?.find((a) => + ['move_to_next', 'move_to_previous', 'move_to_step'].includes(a.type) + ); + return action?.type ?? 'none'; +} + +function hasDisablePlanMode(step: WorkflowStep): boolean { + return step.events?.on_turn_complete?.some((a) => a.type === 'disable_plan_mode') ?? false; +} + +function getTransitionLabel(step: WorkflowStep): string { + const t = getTransitionType(step); + if (t === 'move_to_next') return 'auto'; + if (t === 'move_to_previous') return 'back'; + if (t === 'move_to_step') return 'goto'; + return 'manual'; +} + +// --- Prompt Templates --- + +const PROMPT_TEMPLATES = [ + { + label: 'Plan', + prompt: `Analyze the task and create a detailed implementation plan. + +{{task_prompt}} + +INSTRUCTIONS: +1. Break the task into clear, ordered steps +2. For each step, describe what needs to be done and which files are affected +3. Identify potential risks or blockers +4. Estimate relative complexity for each step (low/medium/high) + +Output the plan as a numbered list. Be specific about file paths, function names, and the approach for each step. Do NOT implement anything yet — only plan.`, + }, + { + label: 'Code Review', + prompt: `Please review the changed files in the current git worktree. + +STEP 1: Determine what to review +- First, check if there are any uncommitted changes (dirty working directory) +- If there are uncommitted/staged changes: review those files +- If the working directory is clean: review the commits that have diverged from the master/main branch + +STEP 2: Review the changes, then output your findings in EXACTLY 4 sections: BUG, IMPROVEMENT, NITPICK, PERFORMANCE. + +Rules: +- Each section is OPTIONAL — only include it if you have findings for that category +- If a section has no findings, omit it entirely +- Format each finding as: filename:line_number - Description +- Be specific and reference exact line numbers +- Keep descriptions concise but actionable +- Sort findings by severity within each section +- Focus on logic and design issues, NOT formatting or style that automated tools handle + +Section definitions: + +BUG: Critical issues that will cause runtime errors, crashes, incorrect behavior, data corruption, or logic errors +- Examples: null/nil dereference, race conditions, incorrect algorithms, type mismatches, resource leaks, deadlocks + +IMPROVEMENT: Code quality, architecture, security, or maintainability concerns +- Examples: missing error handling, incorrect access modifiers, SQL injection vulnerabilities, hardcoded credentials, tight coupling, missing validation + +NITPICK: Significant readability or maintainability issues that impact code understanding +- Examples: misleading variable/function names, overly complex logic that should be refactored, missing critical comments for complex algorithms +- EXCLUDE: formatting, whitespace, import ordering, trivial naming preferences, style issues handled by linters/formatters + +PERFORMANCE: Algorithmic or resource usage problems with measurable impact +- Examples: O(n\u00B2) where O(n) or O(1) is possible, unnecessary allocations in loops, missing indexes for database queries, blocking I/O in hot paths +- Concurrency-specific: unprotected shared state, missing synchronization, goroutine leaks, missing context cancellation + +Now review the changes.`, + }, + { + label: 'Security Audit', + prompt: `Perform a security audit on the changed files in the current git worktree. + +{{task_prompt}} + +Review all changes and check for the following categories: + +1. **Injection Vulnerabilities**: SQL injection, command injection, XSS, template injection, path traversal +2. **Authentication & Authorization**: Missing auth checks, broken access control, privilege escalation, insecure session handling +3. **Data Exposure**: Hardcoded secrets, credentials in logs, sensitive data in error messages, missing encryption +4. **Input Validation**: Missing or insufficient validation at system boundaries, unsafe deserialization, unrestricted file uploads +5. **Dependency Risks**: Known vulnerable dependencies, unsafe use of third-party libraries +6. **Concurrency Issues**: Race conditions on shared state, TOCTOU bugs, unsafe concurrent access to resources + +For each finding, output: +- **Severity**: CRITICAL / HIGH / MEDIUM / LOW +- **Location**: filename:line_number +- **Issue**: What the vulnerability is +- **Impact**: What an attacker could do +- **Fix**: Specific remediation steps + +Only report real, actionable findings. Do not flag theoretical issues without evidence in the code.`, + }, +]; + +// --- Inline help tooltip --- + +function HelpTip({ text }: { text: string }) { + return ( + + + + + + {text} + + + ); +} + +// --- Pipeline Node --- + +type PipelineNodeProps = { + step: WorkflowStep; + isSelected: boolean; + onSelect: () => void; + onRemove: () => void; + readOnly?: boolean; +}; + +function PipelineNode({ step, isSelected, onSelect, onRemove, readOnly = false }: PipelineNodeProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: step.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const hasAuto = hasOnEnterAction(step, 'auto_start_agent'); + const hasPlan = hasOnEnterAction(step, 'enable_plan_mode'); + const hasTransition = getTransitionType(step) !== 'none'; + const hasOnTurnStart = getOnTurnStartTransitionType(step) !== 'none'; + + return ( +
+ {/* Start step indicator */} + {step.is_start_step && ( + + + +
+ +
+
+ Start step +
+
+ )} + + {/* Drag handle */} + + + {/* Content */} +
+ {/* Color dot + Name */} +
+
+ {step.name} +
+ + {/* Event icons */} +
+ {hasOnTurnStart && ( + + + + + + On user message + + + )} + {hasAuto && ( + + + + + + Auto-start agent + + + )} + {hasPlan && ( + + + + + + Plan mode + + + )} + {hasTransition && ( + + + + + + Auto-transition + + + )} + {!hasAuto && !hasPlan && !hasTransition && !hasOnTurnStart && ( + manual + )} +
+
+ + {/* Delete button on hover */} + {!readOnly && ( + + )} +
+ ); +} + +// --- Connector --- + +function PipelineConnector({ label }: { label: string }) { + return ( +
+
+
+ +
+ {label} +
+ ); +} + +// --- Step Config Panel --- + +type StepConfigPanelProps = { + step: WorkflowStep; + steps: WorkflowStep[]; + onUpdate: (updates: Partial) => void; + onRemove: () => void; + readOnly?: boolean; +}; + +function StepConfigPanel({ step, steps, onUpdate, onRemove, readOnly = false }: StepConfigPanelProps) { + const [localName, setLocalName] = useState(step.name); + const [localPrompt, setLocalPrompt] = useState(step.prompt ?? ''); + + const debouncedUpdateName = useDebouncedCallback((name: string) => { + onUpdate({ name }); + }, 500); + + const debouncedUpdatePrompt = useDebouncedCallback((prompt: string) => { + onUpdate({ prompt }); + }, 500); + + const toggleOnEnterAction = (type: string) => { + const currentEvents = step.events ?? {}; + const onEnter = currentEvents.on_enter ?? []; + const exists = onEnter.some((a) => a.type === type); + const newOnEnter = exists + ? onEnter.filter((a) => a.type !== type) + : [...onEnter, { type } as OnEnterAction]; + onUpdate({ events: { ...currentEvents, on_enter: newOnEnter } }); + }; + + const setTransition = (type: string) => { + const currentEvents = step.events ?? {}; + const onTurnComplete = (currentEvents.on_turn_complete ?? []).filter( + (a) => !['move_to_next', 'move_to_previous', 'move_to_step'].includes(a.type) + ); + if (type !== 'none') { + onTurnComplete.push({ type } as OnTurnCompleteAction); + } + onUpdate({ events: { ...currentEvents, on_turn_complete: onTurnComplete } }); + }; + + const setOnTurnStartTransition = (type: string) => { + const currentEvents = step.events ?? {}; + const onTurnStart = (currentEvents.on_turn_start ?? []).filter( + (a) => !['move_to_next', 'move_to_previous', 'move_to_step'].includes(a.type) + ); + if (type !== 'none') { + onTurnStart.push({ type } as OnTurnStartAction); + } + onUpdate({ events: { ...currentEvents, on_turn_start: onTurnStart } }); + }; + + const toggleDisablePlanMode = () => { + const currentEvents = step.events ?? {}; + const onTurnComplete = currentEvents.on_turn_complete ?? []; + const exists = onTurnComplete.some((a) => a.type === 'disable_plan_mode'); + const newOnTurnComplete = exists + ? onTurnComplete.filter((a) => a.type !== 'disable_plan_mode') + : [...onTurnComplete, { type: 'disable_plan_mode' } as OnTurnCompleteAction]; + onUpdate({ events: { ...currentEvents, on_turn_complete: newOnTurnComplete } }); + }; + + // Reset local state only when the step identity changes (not on local edits round-tripping) + const resetKey = step.id; + + return ( +
+ {/* Header */} +
+
+ { + if (readOnly) return; + setLocalName(e.target.value); + debouncedUpdateName(e.target.value); + }} + placeholder="Step name" + disabled={readOnly} + className="max-w-[240px] h-8" + /> + +
+ +
+ + {/* Body */} +
+ {/* Behavior section */} +
+
+
+ { + if (readOnly) return; + onUpdate({ is_start_step: checked === true }); + }} + disabled={readOnly} + /> + + +
+
+ { + if (readOnly) return; + toggleOnEnterAction('auto_start_agent'); + }} + disabled={readOnly} + /> + + +
+
+ { + if (readOnly) return; + toggleOnEnterAction('enable_plan_mode'); + }} + disabled={readOnly} + /> + + +
+
+ { + if (readOnly) return; + onUpdate({ allow_manual_move: checked === true }); + }} + disabled={readOnly} + /> + + +
+
+
+ + {/* Transitions section */} +
+
+ +
+
+ {/* On Turn Start */} +
+
+ + +
+ + + {getOnTurnStartTransitionType(step) === 'move_to_step' && ( + + )} +
+ + {/* On Turn Complete */} +
+
+ + +
+ + + {getTransitionType(step) === 'move_to_step' && ( + + )} + + {hasOnEnterAction(step, 'enable_plan_mode') && ( +
+ { + if (readOnly) return; + toggleDisablePlanMode(); + }} + disabled={readOnly} + /> + + +
+ )} +
+
+
+ + {/* Prompt section */} +
+
+ + +
+ {!readOnly && ( +
+ Templates: + {PROMPT_TEMPLATES.map((template) => ( + + ))} +
+ )} +