Files
kandev/apps/backend/internal/workflow/controller/controller.go
T
2026-08-12 11:12:56 +01:00

369 lines
12 KiB
Go

package controller
import (
"context"
"fmt"
"strings"
"github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/internal/workflow/service"
)
// Controller handles workflow-related requests
type Controller struct {
svc *service.Service
}
// NewController creates a new workflow controller
func NewController(svc *service.Service) *Controller {
return &Controller{svc: svc}
}
// Template responses
type ListTemplatesResponse struct {
Templates []*models.WorkflowTemplate `json:"templates"`
}
type GetTemplateResponse struct {
Template *models.WorkflowTemplate `json:"template"`
}
func (c *Controller) ListTemplates(ctx context.Context) (*ListTemplatesResponse, error) {
templates, err := c.svc.ListTemplates(ctx)
if err != nil {
return nil, err
}
return &ListTemplatesResponse{Templates: templates}, nil
}
func (c *Controller) GetTemplate(ctx context.Context, id string) (*GetTemplateResponse, error) {
template, err := c.svc.GetTemplate(ctx, id)
if err != nil {
return nil, err
}
return &GetTemplateResponse{Template: template}, nil
}
// Step responses
type ListStepsRequest struct {
WorkflowID string `json:"workflow_id"`
}
type ListStepsResponse struct {
Steps []*models.WorkflowStep `json:"steps"`
}
type GetStepResponse struct {
Step *models.WorkflowStep `json:"step"`
DemotedStartSteps []*models.WorkflowStep `json:"demoted_start_steps,omitempty"`
}
type CreateStepsFromTemplateRequest struct {
WorkflowID string `json:"workflow_id"`
TemplateID string `json:"template_id"`
}
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
}
return &ListStepsResponse{Steps: steps}, nil
}
// ListStepsByWorkspace returns all workflow steps for all workflows in a workspace.
func (c *Controller) ListStepsByWorkspace(ctx context.Context, workspaceID string) (*ListStepsResponse, error) {
steps, err := c.svc.ListStepsByWorkspaceID(ctx, workspaceID)
if err != nil {
return nil, err
}
return &ListStepsResponse{Steps: steps}, nil
}
func (c *Controller) GetStep(ctx context.Context, id string) (*GetStepResponse, error) {
step, err := c.svc.GetStep(ctx, id)
if err != nil {
return nil, err
}
return &GetStepResponse{Step: step}, nil
}
func (c *Controller) CreateStepsFromTemplate(ctx context.Context, req CreateStepsFromTemplateRequest) error {
if err := c.svc.EnsureWorkflowMutable(ctx, req.WorkflowID); err != nil {
return err
}
return c.svc.CreateStepsFromTemplate(ctx, req.WorkflowID, req.TemplateID)
}
// CreateStepRequest is the request for creating a single workflow step.
type CreateStepRequest struct {
WorkflowID string `json:"workflow_id"`
Name string `json:"name"`
Position int `json:"position"`
Color string `json:"color"`
StageType *models.StageType `json:"stage_type,omitempty"`
Prompt string `json:"prompt,omitempty"`
Events *models.StepEvents `json:"events,omitempty"`
AllowManualMove bool `json:"allow_manual_move"`
IsStartStep *bool `json:"is_start_step,omitempty"`
ShowInCommandPanel *bool `json:"show_in_command_panel,omitempty"`
AutoAdvanceRequiresSignal *bool `json:"auto_advance_requires_signal,omitempty"`
CancelTriggersTurnComplete *bool `json:"cancel_triggers_turn_complete,omitempty"`
WIPLimit *int `json:"wip_limit,omitempty"`
PullFromStepID *string `json:"pull_from_step_id,omitempty"`
}
// CreateStep creates a new workflow step.
func (c *Controller) CreateStep(ctx context.Context, req CreateStepRequest) (*GetStepResponse, error) {
if err := c.svc.EnsureWorkflowMutable(ctx, req.WorkflowID); err != nil {
return nil, err
}
step := &models.WorkflowStep{
WorkflowID: req.WorkflowID,
Name: req.Name,
Position: req.Position,
Color: req.Color,
Prompt: req.Prompt,
AllowManualMove: req.AllowManualMove,
}
if req.Events != nil {
step.Events = *req.Events
}
if req.StageType != nil {
step.StageType = *req.StageType
}
if req.IsStartStep != nil {
step.IsStartStep = *req.IsStartStep
}
if req.ShowInCommandPanel != nil {
step.ShowInCommandPanel = *req.ShowInCommandPanel
} else {
step.ShowInCommandPanel = true // default to visible
}
if req.AutoAdvanceRequiresSignal != nil {
step.AutoAdvanceRequiresSignal = *req.AutoAdvanceRequiresSignal
}
if req.CancelTriggersTurnComplete != nil {
step.CancelTriggersTurnComplete = *req.CancelTriggersTurnComplete
}
if req.WIPLimit != nil {
if *req.WIPLimit < 0 {
return nil, fmt.Errorf("wip_limit must be non-negative")
}
step.WIPLimit = *req.WIPLimit
}
if req.PullFromStepID != nil {
step.PullFromStepID = strings.TrimSpace(*req.PullFromStepID)
}
if err := c.validatePullFromStep(ctx, step); err != nil {
return nil, err
}
demotedStartSteps, err := c.svc.CreateStepWithStartStepUpdates(ctx, step)
if err != nil {
return nil, err
}
return &GetStepResponse{Step: step, DemotedStartSteps: demotedStartSteps}, nil
}
// UpdateStepRequest is the request for updating a workflow step.
type UpdateStepRequest struct {
ID string `json:"id"`
Name *string `json:"name,omitempty"`
Position *int `json:"position,omitempty"`
Color *string `json:"color,omitempty"`
StageType *models.StageType `json:"stage_type,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"`
ShowInCommandPanel *bool `json:"show_in_command_panel,omitempty"`
AutoArchiveAfterHours *int `json:"auto_archive_after_hours,omitempty"`
AgentProfileID *string `json:"agent_profile_id,omitempty"`
AutoAdvanceRequiresSignal *bool `json:"auto_advance_requires_signal,omitempty"`
CancelTriggersTurnComplete *bool `json:"cancel_triggers_turn_complete,omitempty"`
WIPLimit *int `json:"wip_limit,omitempty"`
PullFromStepID *string `json:"pull_from_step_id,omitempty"`
}
// UpdateStep updates an existing workflow step.
func (c *Controller) UpdateStep(ctx context.Context, req UpdateStepRequest) (*GetStepResponse, error) {
step, err := c.svc.GetStep(ctx, req.ID)
if err != nil {
return nil, err
}
if err := c.svc.EnsureWorkflowMutable(ctx, step.WorkflowID); err != nil {
return nil, err
}
if req.Name != nil {
step.Name = *req.Name
}
if req.Position != nil {
step.Position = *req.Position
}
if req.Color != nil {
step.Color = *req.Color
}
if req.Prompt != nil {
step.Prompt = *req.Prompt
}
if req.Events != nil {
step.Events = *req.Events
}
if req.StageType != nil {
step.StageType = *req.StageType
}
if req.AllowManualMove != nil {
step.AllowManualMove = *req.AllowManualMove
}
if req.IsStartStep != nil {
step.IsStartStep = *req.IsStartStep
}
if req.ShowInCommandPanel != nil {
step.ShowInCommandPanel = *req.ShowInCommandPanel
}
if req.AutoArchiveAfterHours != nil {
step.AutoArchiveAfterHours = *req.AutoArchiveAfterHours
}
if req.AgentProfileID != nil {
step.AgentProfileID = strings.TrimSpace(*req.AgentProfileID)
}
if req.AutoAdvanceRequiresSignal != nil {
step.AutoAdvanceRequiresSignal = *req.AutoAdvanceRequiresSignal
}
if req.CancelTriggersTurnComplete != nil {
step.CancelTriggersTurnComplete = *req.CancelTriggersTurnComplete
}
if req.WIPLimit != nil {
if *req.WIPLimit < 0 {
return nil, fmt.Errorf("wip_limit must be non-negative")
}
step.WIPLimit = *req.WIPLimit
}
if req.PullFromStepID != nil {
step.PullFromStepID = strings.TrimSpace(*req.PullFromStepID)
}
if err := c.validatePullFromStep(ctx, step); err != nil {
return nil, err
}
demotedStartSteps, err := c.svc.UpdateStepWithStartStepUpdates(ctx, step)
if err != nil {
return nil, err
}
return &GetStepResponse{Step: step, DemotedStartSteps: demotedStartSteps}, nil
}
func (c *Controller) validatePullFromStep(ctx context.Context, step *models.WorkflowStep) error {
if step.PullFromStepID == "" {
return nil
}
if step.ID != "" && step.PullFromStepID == step.ID {
return fmt.Errorf("pull_from_step_id cannot reference the same step")
}
source, err := c.svc.GetStep(ctx, step.PullFromStepID)
if err != nil {
return fmt.Errorf("pull_from_step_id is invalid: %w", err)
}
if source.WorkflowID != step.WorkflowID {
return fmt.Errorf("pull_from_step_id must reference a step in the same workflow")
}
if err := c.validatePullFromStepAcyclic(ctx, step.ID, source); err != nil {
return err
}
return nil
}
func (c *Controller) validatePullFromStepAcyclic(ctx context.Context, stepID string, source *models.WorkflowStep) error {
if stepID == "" {
return nil
}
seen := map[string]struct{}{}
for current := source; current != nil && current.PullFromStepID != ""; {
if current.PullFromStepID == stepID {
return fmt.Errorf("pull_from_step_id cannot create a pull cycle")
}
if _, ok := seen[current.ID]; ok {
return fmt.Errorf("pull_from_step_id cannot create a pull cycle")
}
seen[current.ID] = struct{}{}
next, err := c.svc.GetStep(ctx, current.PullFromStepID)
if err != nil {
return fmt.Errorf("pull_from_step_id is invalid: %w", err)
}
if next.WorkflowID != source.WorkflowID {
return fmt.Errorf("pull_from_step_id must reference a step in the same workflow")
}
current = next
}
return nil
}
// DeleteStep deletes a workflow step.
func (c *Controller) DeleteStep(ctx context.Context, id string) error {
step, err := c.svc.GetStep(ctx, id)
if err != nil {
return err
}
if err := c.svc.EnsureWorkflowMutable(ctx, step.WorkflowID); err != nil {
return err
}
return c.svc.DeleteStep(ctx, id)
}
// ReorderStepsRequest is the request for reordering workflow steps.
type ReorderStepsRequest struct {
WorkflowID string `json:"workflow_id"`
StepIDs []string `json:"step_ids"`
}
// ReorderSteps reorders workflow steps for a workflow.
func (c *Controller) ReorderSteps(ctx context.Context, req ReorderStepsRequest) error {
if err := c.svc.EnsureWorkflowMutable(ctx, req.WorkflowID); err != nil {
return err
}
return c.svc.ReorderSteps(ctx, req.WorkflowID, req.StepIDs)
}
// History responses
type ListHistoryRequest struct {
SessionID string `json:"session_id"`
}
type ListHistoryResponse struct {
History []*models.SessionStepHistory `json:"history"`
}
func (c *Controller) ListHistoryBySession(ctx context.Context, req ListHistoryRequest) (*ListHistoryResponse, error) {
history, err := c.svc.ListHistoryBySession(ctx, req.SessionID)
if err != nil {
return nil, err
}
return &ListHistoryResponse{History: history}, nil
}
// Export/Import types and methods
// ImportWorkflowsRequest carries import data.
type ImportWorkflowsRequest struct {
WorkspaceID string `json:"workspace_id"`
Data *models.WorkflowExport `json:"data"`
}
// ExportWorkflow exports a single workflow.
func (c *Controller) ExportWorkflow(ctx context.Context, workflowID string) (*models.WorkflowExport, error) {
return c.svc.ExportWorkflow(ctx, workflowID)
}
// ExportWorkflows exports workflows for a workspace. A nil workflowIDs exports
// every workflow; a non-nil slice restricts the export to that set of IDs.
func (c *Controller) ExportWorkflows(ctx context.Context, workspaceID string, workflowIDs []string) (*models.WorkflowExport, error) {
return c.svc.ExportWorkflows(ctx, workspaceID, workflowIDs)
}
// ImportWorkflows imports workflows into a workspace.
func (c *Controller) ImportWorkflows(ctx context.Context, req ImportWorkflowsRequest) (*service.ImportResult, error) {
return c.svc.ImportWorkflows(ctx, req.WorkspaceID, req.Data)
}