Files
kandev/apps/backend/internal/workflow/service/service.go
T

543 lines
20 KiB
Go

// Package service provides workflow business logic operations.
package service
import (
"context"
"fmt"
"github.com/google/uuid"
"go.uber.org/zap"
workflowcfg "github.com/kandev/kandev/config/workflows"
"github.com/kandev/kandev/internal/common/logger"
taskmodels "github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/internal/workflow/repository"
)
// WorkflowProvider gives the workflow service access to workflows owned by the task domain.
type WorkflowProvider interface {
ListWorkflows(ctx context.Context, workspaceID string, includeHidden bool) ([]*taskmodels.Workflow, error)
GetWorkflow(ctx context.Context, id string) (*taskmodels.Workflow, error)
CreateWorkflow(ctx context.Context, workspaceID, name, description string) (*taskmodels.Workflow, error)
UpdateWorkflow(ctx context.Context, workflow *taskmodels.Workflow) error
}
// Service provides workflow business logic
type Service struct {
repo *repository.Repository
logger *logger.Logger
workflowProvider WorkflowProvider
resolveProfile models.AgentProfileResolver
matchProfile models.AgentProfileMatcher
}
// SetWorkflowProvider wires the workflow provider (set during service init to break circular deps).
func (s *Service) SetWorkflowProvider(wp WorkflowProvider) {
s.workflowProvider = wp
}
// SetAgentProfileFuncs wires the agent profile resolver and matcher for export/import.
func (s *Service) SetAgentProfileFuncs(resolve models.AgentProfileResolver, match models.AgentProfileMatcher) {
s.resolveProfile = resolve
s.matchProfile = match
}
// NewService creates a new workflow service
func NewService(repo *repository.Repository, log *logger.Logger) *Service {
return &Service{
repo: repo,
logger: log.WithFields(zap.String("component", "workflow-service")),
}
}
// ============================================================================
// Template Operations
// ============================================================================
// ListTemplates returns user-pickable workflow templates. Templates marked
// `hidden: true` in their embedded YAML (e.g. improve-kandev) are excluded
// from the management UI and the create-workflow picker.
func (s *Service) ListTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) {
templates, err := s.repo.ListTemplates(ctx)
if err != nil {
s.logger.Error("failed to list templates", zap.Error(err))
return nil, err
}
hidden, err := workflowcfg.HiddenTemplateIDs()
if err != nil {
s.logger.Error("failed to load embedded template visibility", zap.Error(err))
return nil, err
}
result := make([]*models.WorkflowTemplate, 0, len(templates))
for _, t := range templates {
if hidden[t.ID] {
continue
}
result = append(result, t)
}
return result, nil
}
// GetTemplate retrieves a workflow template by ID.
func (s *Service) GetTemplate(ctx context.Context, id string) (*models.WorkflowTemplate, error) {
template, err := s.repo.GetTemplate(ctx, id)
if err != nil {
s.logger.Error("failed to get template", zap.String("template_id", id), zap.Error(err))
return nil, err
}
return template, nil
}
// GetSystemTemplates returns only system workflow templates.
func (s *Service) GetSystemTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) {
templates, err := s.repo.GetSystemTemplates(ctx)
if err != nil {
s.logger.Error("failed to get system templates", zap.Error(err))
return nil, err
}
return templates, nil
}
// ============================================================================
// Step Operations
// ============================================================================
// 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 workflow", zap.String("workflow_id", workflowID), zap.Error(err))
return nil, err
}
return steps, nil
}
// ListStepsByWorkspaceID returns all workflow steps for all workflows in a workspace.
func (s *Service) ListStepsByWorkspaceID(ctx context.Context, workspaceID string) ([]*models.WorkflowStep, error) {
steps, err := s.repo.ListStepsByWorkspaceID(ctx, workspaceID)
if err != nil {
s.logger.Error("failed to list steps by workspace", zap.String("workspace_id", workspaceID), zap.Error(err))
return nil, err
}
return steps, nil
}
// GetStep retrieves a workflow step by ID.
func (s *Service) GetStep(ctx context.Context, stepID string) (*models.WorkflowStep, error) {
step, err := s.repo.GetStep(ctx, stepID)
if err != nil {
s.logger.Error("failed to get step", zap.String("step_id", stepID), zap.Error(err))
return nil, err
}
return step, nil
}
// 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, 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("workflow_id", workflowID),
zap.Error(err))
return nil, err
}
// Steps are already ordered by position from ListStepsByWorkflow
for _, step := range steps {
if step.Position > currentPosition {
return step, nil
}
}
return nil, nil // No next step found (current step is the last one)
}
// GetWorkflowAgentProfileID returns the default agent profile ID for a workflow.
func (s *Service) GetWorkflowAgentProfileID(ctx context.Context, workflowID string) (string, error) {
wf, err := s.workflowProvider.GetWorkflow(ctx, workflowID)
if err != nil {
return "", err
}
return wf.AgentProfileID, nil
}
// 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",
zap.String("template_id", templateID),
zap.Error(err))
return fmt.Errorf("failed to get template: %w", err)
}
// 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, remapping step_id references in events
for _, stepDef := range template.Steps {
events := models.RemapStepEvents(stepDef.Events, idMap)
step := &models.WorkflowStep{
ID: idMap[stepDef.ID],
WorkflowID: workflowID,
Name: stepDef.Name,
Position: stepDef.Position,
Color: stepDef.Color,
Prompt: stepDef.Prompt,
Events: events,
AllowManualMove: stepDef.AllowManualMove,
IsStartStep: stepDef.IsStartStep,
ShowInCommandPanel: stepDef.ShowInCommandPanel,
AutoArchiveAfterHours: stepDef.AutoArchiveAfterHours,
AgentProfileID: stepDef.AgentProfileID,
StageType: stepDef.StageType,
}
if err := s.repo.CreateStep(ctx, step); err != nil {
s.logger.Error("failed to create step from template",
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)
}
}
s.logger.Info("created workflow steps from template",
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 by position.
func (s *Service) ResolveStartStep(ctx context.Context, workflowID string) (*models.WorkflowStep, error) {
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 by position
return s.ResolveFirstStep(ctx, workflowID)
}
// ResolveFirstStep always returns the first step by position, ignoring is_start_step.
func (s *Service) ResolveFirstStep(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 for first 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)
}
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("workflow_id", step.WorkflowID), zap.Error(err))
return err
}
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
}
s.logger.Info("updated workflow step", zap.String("step_id", step.ID))
return nil
}
// 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 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 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("workflow_id", step.WorkflowID),
zap.Error(err))
return err
}
// Now delete the step
if err := s.repo.DeleteStep(ctx, stepID); err != nil {
s.logger.Error("failed to delete step", zap.String("step_id", stepID), zap.Error(err))
return err
}
s.logger.Info("deleted workflow step and cleared references",
zap.String("step_id", stepID),
zap.String("workflow_id", step.WorkflowID))
return nil
}
// 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 {
s.logger.Error("failed to get step for reorder", zap.String("step_id", stepID), zap.Error(err))
return err
}
step.Position = i
if err := s.repo.UpdateStep(ctx, step); err != nil {
s.logger.Error("failed to update step position", zap.String("step_id", stepID), zap.Error(err))
return err
}
}
s.logger.Info("reordered workflow steps", zap.String("workflow_id", workflowID), zap.Int("count", len(stepIDs)))
return nil
}
// ============================================================================
// History Operations
// ============================================================================
// CreateStepTransition creates a new step transition history entry.
func (s *Service) CreateStepTransition(ctx context.Context, sessionID string, fromStepID, toStepID string, trigger models.StepTransitionTrigger, actorID *string) error {
history := &models.SessionStepHistory{
SessionID: sessionID,
ToStepID: toStepID,
Trigger: trigger,
ActorID: actorID,
}
if fromStepID != "" {
history.FromStepID = &fromStepID
}
if err := s.repo.CreateHistory(ctx, history); err != nil {
s.logger.Error("failed to create step transition",
zap.String("session_id", sessionID),
zap.String("to_step_id", toStepID),
zap.Error(err))
return err
}
s.logger.Info("step transition recorded",
zap.String("session_id", sessionID),
zap.Stringp("from_step_id", history.FromStepID),
zap.String("to_step_id", toStepID),
zap.String("trigger", string(trigger)))
return nil
}
// ListHistoryBySession returns all step history entries for a session.
func (s *Service) ListHistoryBySession(ctx context.Context, sessionID string) ([]*models.SessionStepHistory, error) {
history, err := s.repo.ListHistoryBySession(ctx, sessionID)
if err != nil {
s.logger.Error("failed to list history by session", zap.String("session_id", sessionID), zap.Error(err))
return nil, err
}
return history, nil
}
// ============================================================================
// Export/Import Operations
// ============================================================================
// ImportResult holds the outcome of an import operation.
type ImportResult struct {
Created []string `json:"created"`
Skipped []string `json:"skipped"`
}
// ExportWorkflow exports a single workflow with its steps as portable JSON.
func (s *Service) ExportWorkflow(ctx context.Context, workflowID string) (*models.WorkflowExport, error) {
wf, err := s.workflowProvider.GetWorkflow(ctx, workflowID)
if err != nil {
return nil, fmt.Errorf("failed to get workflow: %w", err)
}
steps, err := s.repo.ListStepsByWorkflow(ctx, workflowID)
if err != nil {
return nil, fmt.Errorf("failed to list steps: %w", err)
}
stepMap := map[string][]*models.WorkflowStep{wf.ID: steps}
return models.BuildWorkflowExport([]*taskmodels.Workflow{wf}, stepMap, s.resolveProfile), nil
}
// ExportWorkflows exports workflows for a workspace. When workflowIDs is nil,
// every (non-hidden) workflow is exported (back-compat). When workflowIDs is
// non-nil, only workflows whose ID is in the set are exported — an empty set
// exports nothing. Filtering is by ID membership only: the backend MUST NOT
// branch on a workflow's style (see internal/workflow/models/phase2.go), so the
// frontend decides which workflows (e.g. kanban-only) to include and passes
// their IDs.
//
// Hidden/system workflows (e.g. improve-kandev) are only listed when the caller
// passes explicit IDs; the back-compat "export everything" path leaves them out
// so a bare GET of the export endpoint can't leak system flows.
func (s *Service) ExportWorkflows(ctx context.Context, workspaceID string, workflowIDs []string) (*models.WorkflowExport, error) {
includeHidden := workflowIDs != nil
workflows, err := s.workflowProvider.ListWorkflows(ctx, workspaceID, includeHidden)
if err != nil {
return nil, fmt.Errorf("failed to list workflows: %w", err)
}
if workflowIDs != nil {
workflows = filterWorkflowsByID(workflows, workflowIDs)
}
stepMap := make(map[string][]*models.WorkflowStep, len(workflows))
for _, wf := range workflows {
steps, stepsErr := s.repo.ListStepsByWorkflow(ctx, wf.ID)
if stepsErr != nil {
return nil, fmt.Errorf("failed to list steps for workflow %s: %w", wf.ID, stepsErr)
}
stepMap[wf.ID] = steps
}
return models.BuildWorkflowExport(workflows, stepMap, s.resolveProfile), nil
}
// filterWorkflowsByID returns the subset of workflows whose ID is in ids,
// preserving the input order.
func filterWorkflowsByID(workflows []*taskmodels.Workflow, ids []string) []*taskmodels.Workflow {
want := make(map[string]bool, len(ids))
for _, id := range ids {
want[id] = true
}
filtered := make([]*taskmodels.Workflow, 0, len(workflows))
for _, wf := range workflows {
if want[wf.ID] {
filtered = append(filtered, wf)
}
}
return filtered
}
// ImportWorkflows imports workflows into a workspace. Deduplicates by name.
func (s *Service) ImportWorkflows(ctx context.Context, workspaceID string, export *models.WorkflowExport) (*ImportResult, error) {
if err := export.Validate(); err != nil {
return nil, fmt.Errorf("invalid export data: %w", err)
}
existing, err := s.workflowProvider.ListWorkflows(ctx, workspaceID, true)
if err != nil {
return nil, fmt.Errorf("failed to list existing workflows: %w", err)
}
existingNames := make(map[string]bool, len(existing))
for _, wf := range existing {
existingNames[wf.Name] = true
}
result := &ImportResult{}
for _, pw := range export.Workflows {
if existingNames[pw.Name] {
result.Skipped = append(result.Skipped, pw.Name)
continue
}
if err := s.importSingleWorkflow(ctx, workspaceID, pw); err != nil {
return nil, fmt.Errorf("failed to import workflow %q: %w", pw.Name, err)
}
result.Created = append(result.Created, pw.Name)
}
s.logger.Info("imported workflows",
zap.String("workspace_id", workspaceID),
zap.Int("created", len(result.Created)),
zap.Int("skipped", len(result.Skipped)))
return result, nil
}
func (s *Service) importSingleWorkflow(ctx context.Context, workspaceID string, pw models.WorkflowPortable) error {
wf, err := s.workflowProvider.CreateWorkflow(ctx, workspaceID, pw.Name, pw.Description)
if err != nil {
return fmt.Errorf("create workflow: %w", err)
}
// Match workflow-level agent profile if present.
if pw.AgentProfile != nil && s.matchProfile != nil {
if profileID := s.matchProfile(pw.AgentProfile.AgentName, pw.AgentProfile.Model, pw.AgentProfile.Mode); profileID != "" {
wf.AgentProfileID = profileID
if err := s.workflowProvider.UpdateWorkflow(ctx, wf); err != nil {
return fmt.Errorf("set workflow agent profile: %w", err)
}
}
}
// Generate UUIDs for all steps and build position→ID map.
posToID := make(map[int]string, len(pw.Steps))
for _, sp := range pw.Steps {
posToID[sp.Position] = uuid.New().String()
}
// Create each step with remapped events.
for _, sp := range pw.Steps {
step := &models.WorkflowStep{
ID: posToID[sp.Position],
WorkflowID: wf.ID,
Name: sp.Name,
Position: sp.Position,
Color: sp.Color,
Prompt: sp.Prompt,
Events: models.ConvertPositionToStepID(sp.Events, posToID),
IsStartStep: sp.IsStartStep,
ShowInCommandPanel: sp.ShowInCommandPanel,
AllowManualMove: sp.AllowManualMove,
AutoArchiveAfterHours: sp.AutoArchiveAfterHours,
}
// Match step-level agent profile if present.
if sp.AgentProfile != nil && s.matchProfile != nil {
step.AgentProfileID = s.matchProfile(sp.AgentProfile.AgentName, sp.AgentProfile.Model, sp.AgentProfile.Mode)
}
if err := s.repo.CreateStep(ctx, step); err != nil {
return fmt.Errorf("create step %q: %w", sp.Name, err)
}
}
return nil
}