package controller import ( "bytes" "context" "encoding/json" "fmt" "strings" taskmodels "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/workflow/models" "github.com/kandev/kandev/internal/workflow/service" ) // Per-user scoping lives here rather than in the HTTP handlers because the // controller is the single entry point every user-facing transport shares: // the REST routes, the WebSocket actions (which the gateway's dispatch // backstop cannot cover — it parses no workflow_id or step id) and the MCP // tool handlers. A guard added one layer up would leave the other two open. // The checks themselves belong to the task domain and reach it through the // service's Authorize* helpers (internal/workflow/service/access.go). // // Workflow templates are deliberately unscoped: they are install-global, // read-only definitions with no owner. // 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) { if err := c.svc.AuthorizeWorkflow(ctx, req.WorkflowID); err != nil { return nil, err } 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) { // Authorized inside ListStepsByWorkspaceID rather than here: the // boot-payload builder calls that method without passing through this // controller, so the service is the layer both entry points share. 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) { if err := c.svc.AuthorizeStep(ctx, id); err != nil { return nil, err } 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.AuthorizeWorkflow(ctx, req.WorkflowID); err != nil { return err } 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"` AgentProfileID *string `json:"agent_profile_id,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"` CompleteTaskOnEnter *bool `json:"complete_task_on_enter,omitempty"` ProfileSessionStartPolicy *string `json:"profile_session_start_policy,omitempty"` ProfileSessionEndPolicy *string `json:"profile_session_end_policy,omitempty"` SessionTarget SessionTargetPatch `json:"session_target,omitempty"` WIPLimit *int `json:"wip_limit,omitempty"` PullFromStepID *string `json:"pull_from_step_id,omitempty"` } func rejectNullCompleteTaskOnEnter(data []byte) error { var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { return err } if raw, ok := fields["complete_task_on_enter"]; ok && bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return fmt.Errorf("complete_task_on_enter must be a boolean") } return nil } // UnmarshalJSON keeps omission distinct from an explicit null. The REST // boundary uses nil to mean the documented default for create and no-op for // update, so null must be rejected instead of silently taking either path. func (r *CreateStepRequest) UnmarshalJSON(data []byte) error { if err := rejectNullCompleteTaskOnEnter(data); err != nil { return err } type requestAlias CreateStepRequest var decoded requestAlias if err := json.Unmarshal(data, &decoded); err != nil { return err } *r = CreateStepRequest(decoded) return nil } // CreateStep creates a new workflow step. func (c *Controller) CreateStep(ctx context.Context, req CreateStepRequest) (*GetStepResponse, error) { // The new step names its workflow in the body; authorize that workflow // before anything else reads or writes. if err := c.svc.AuthorizeWorkflow(ctx, req.WorkflowID); err != nil { return nil, err } 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.AgentProfileID != nil { step.AgentProfileID = strings.TrimSpace(*req.AgentProfileID) } if req.ProfileSessionStartPolicy != nil { step.ProfileSessionStartPolicy = taskmodels.NormalizeWorkflowProfileSessionStartPolicy(*req.ProfileSessionStartPolicy) } if req.ProfileSessionEndPolicy != nil { step.ProfileSessionEndPolicy = taskmodels.NormalizeWorkflowProfileSessionEndPolicy(*req.ProfileSessionEndPolicy) } if req.SessionTarget.Set { step.SessionTarget = models.CloneWorkflowSessionTarget(req.SessionTarget.Target) } if req.AutoAdvanceRequiresSignal != nil { step.AutoAdvanceRequiresSignal = *req.AutoAdvanceRequiresSignal } if req.CancelTriggersTurnComplete != nil { step.CancelTriggersTurnComplete = *req.CancelTriggersTurnComplete } if req.CompleteTaskOnEnter != nil { step.CompleteTaskOnEnter = *req.CompleteTaskOnEnter } 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.validateStepReferences(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"` CompleteTaskOnEnter *bool `json:"complete_task_on_enter,omitempty"` ProfileSessionStartPolicy *string `json:"profile_session_start_policy,omitempty"` ProfileSessionEndPolicy *string `json:"profile_session_end_policy,omitempty"` SessionTarget SessionTargetPatch `json:"session_target,omitempty"` WIPLimit *int `json:"wip_limit,omitempty"` PullFromStepID *string `json:"pull_from_step_id,omitempty"` } // UnmarshalJSON rejects null while preserving omission semantics for updates. func (r *UpdateStepRequest) UnmarshalJSON(data []byte) error { if err := rejectNullCompleteTaskOnEnter(data); err != nil { return err } type requestAlias UpdateStepRequest var decoded requestAlias if err := json.Unmarshal(data, &decoded); err != nil { return err } *r = UpdateStepRequest(decoded) return nil } // UpdateStep updates an existing workflow step. func (c *Controller) UpdateStep(ctx context.Context, req UpdateStepRequest) (*GetStepResponse, error) { if err := c.svc.AuthorizeStep(ctx, req.ID); err != nil { return nil, err } 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.ProfileSessionStartPolicy != nil { step.ProfileSessionStartPolicy = taskmodels.NormalizeWorkflowProfileSessionStartPolicy(*req.ProfileSessionStartPolicy) } if req.ProfileSessionEndPolicy != nil { step.ProfileSessionEndPolicy = taskmodels.NormalizeWorkflowProfileSessionEndPolicy(*req.ProfileSessionEndPolicy) } if req.SessionTarget.Set { step.SessionTarget = models.CloneWorkflowSessionTarget(req.SessionTarget.Target) } if req.AutoAdvanceRequiresSignal != nil { step.AutoAdvanceRequiresSignal = *req.AutoAdvanceRequiresSignal } if req.CancelTriggersTurnComplete != nil { step.CancelTriggersTurnComplete = *req.CancelTriggersTurnComplete } if req.CompleteTaskOnEnter != nil { step.CompleteTaskOnEnter = *req.CompleteTaskOnEnter } 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.validateStepReferences(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 } // validateStepReferences checks every ID the step carries that names another // resource: its pull source, its move_to_step transition targets, and the // tasks its queue_run actions would start work on. // // Two different rejections, deliberately. A reference the caller cannot see is // not-found, indistinguishable from one that does not exist. A step the caller // *can* see but that lives in another workflow is a validation error naming // the reason, because there is nothing to hide from them and the editor has to // be able to explain it. func (c *Controller) validateStepReferences(ctx context.Context, step *models.WorkflowStep) error { if err := c.validateSessionTarget(ctx, step); err != nil { return err } if err := c.validateIncomingSessionTargets(ctx, step.WorkflowID, step, nil); err != nil { return err } if err := c.validatePullFromStep(ctx, step); err != nil { return err } refs := models.CollectStepEventReferences(step.Events) for _, targetID := range refs.StepIDs { if err := c.validateEventStepTarget(ctx, step, targetID); err != nil { return err } } for _, taskID := range refs.TaskIDs { if err := c.svc.AuthorizeTask(ctx, taskID); err != nil { return err } } return nil } func (c *Controller) validateSessionTarget(ctx context.Context, step *models.WorkflowStep) error { if err := models.ValidateWorkflowSessionTarget(step.SessionTarget); err != nil { return fmt.Errorf("session_target is invalid: %w", err) } if step.SessionTarget == nil { return nil } if step.AgentProfileID != "" { return fmt.Errorf("session_target cannot be combined with agent_profile_id") } if step.SessionTarget.Kind == models.WorkflowSessionTargetInitial { return nil } if step.ID != "" && step.SessionTarget.StepID == step.ID { return fmt.Errorf("session_target cannot reference the same step") } if err := c.svc.AuthorizeStep(ctx, step.SessionTarget.StepID); err != nil { return fmt.Errorf("session_target is invalid: %w", err) } target, err := c.svc.GetStep(ctx, step.SessionTarget.StepID) if err != nil { return fmt.Errorf("session_target is invalid: %w", err) } if target.WorkflowID != step.WorkflowID { return fmt.Errorf("session_target must reference a step in the same workflow") } if target.Position >= step.Position { return fmt.Errorf("session_target must reference an earlier step") } if target.AgentProfileID == "" { return fmt.Errorf("session_target must reference a step with an agent profile") } if target.SessionTarget != nil { return fmt.Errorf("session_target cannot reference a step with another session target") } return nil } // validateIncomingSessionTargets validates references pointing at the step // being written, not only the target carried by that step. This prevents a // profile removal or position edit from silently invalidating dependent // destinations. func (c *Controller) validateIncomingSessionTargets( ctx context.Context, workflowID string, replacement *models.WorkflowStep, positions map[string]int, ) error { steps, err := c.svc.ListStepsByWorkflow(ctx, workflowID) if err != nil { return err } byID := make(map[string]*models.WorkflowStep, len(steps)+1) for _, existing := range steps { if existing != nil { byID[existing.ID] = existing } } if replacement != nil { byID[replacement.ID] = replacement } for _, current := range byID { if current == nil || current.SessionTarget == nil || current.SessionTarget.Kind != models.WorkflowSessionTargetStep { continue } if err := validateIncomingSessionTarget(workflowID, current, byID, positions); err != nil { return err } } return nil } func validateIncomingSessionTarget( workflowID string, current *models.WorkflowStep, byID map[string]*models.WorkflowStep, positions map[string]int, ) error { source, ok := byID[current.SessionTarget.StepID] if !ok || source == nil { return fmt.Errorf("session_target source step %q was not found", current.SessionTarget.StepID) } currentPosition := current.Position sourcePosition := source.Position if positions != nil { if value, found := positions[current.ID]; found { currentPosition = value } if value, found := positions[source.ID]; found { sourcePosition = value } } if source.WorkflowID != workflowID || source.WorkflowID != current.WorkflowID { return fmt.Errorf("session_target must reference a step in the same workflow") } if sourcePosition >= currentPosition { return fmt.Errorf("session_target source step %q must remain earlier than %q", source.ID, current.ID) } if source.AgentProfileID == "" || source.SessionTarget != nil { return fmt.Errorf("session_target source step %q must keep a direct agent profile", source.ID) } return nil } // validateEventStepTarget keeps a transition inside its own workflow. The // engine dereferences the target when the trigger fires and drives the task // with whatever it finds there — that step's prompt and agent profile — so a // target in somebody else's workflow both parks the task outside its board and // runs it under their configuration. // // A target that resolves to nothing is left alone, because reaching nothing is // the whole point: `move_to_step` configs are authored with symbolic IDs // ("review", "in-progress") that only the template applier remaps, and the // engine skips an unresolvable target rather than failing the trigger. That // tolerance is load-bearing product behaviour, not an oversight, so rejecting // it here broke ordinary step edits that had nothing to do with scoping. // // It is also safe: an unresolvable ID reaches no other user's step, and it // cannot be armed later, since step IDs are generated server-side on every // write path (create, template apply, import, sync) and never accepted from a // caller. func (c *Controller) validateEventStepTarget(ctx context.Context, step *models.WorkflowStep, targetID string) error { if targetID == step.ID { return nil // a self-transition names no other step } target, err := c.svc.GetStep(ctx, targetID) if err != nil { if service.IsNotFound(err) { return nil } return fmt.Errorf("move_to_step target is invalid: %w", err) } // Membership carries the authorization: the step being written lives in an // already-authorized workflow, so a target inside that same workflow is // authorized by construction, and one outside it is refused whether or not // the caller can see it. if target.WorkflowID != step.WorkflowID { return fmt.Errorf("move_to_step must reference a step in the same workflow") } return 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") } // Authorizing the pull source keeps a foreign step from being told apart // from a nonexistent one: without this, "must reference a step in the same // workflow" would confirm that somebody else's step ID exists. if err := c.svc.AuthorizeStep(ctx, step.PullFromStepID); err != nil { return fmt.Errorf("pull_from_step_id is invalid: %w", err) } 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 { if err := c.svc.AuthorizeStep(ctx, id); err != nil { return err } step, err := c.svc.GetStep(ctx, id) if err != nil { return err } if err := c.svc.EnsureWorkflowMutable(ctx, step.WorkflowID); err != nil { return err } steps, err := c.svc.ListStepsByWorkflow(ctx, step.WorkflowID) if err != nil { return err } for _, dependent := range steps { if dependent != nil && dependent.SessionTarget != nil && dependent.SessionTarget.Kind == models.WorkflowSessionTargetStep && dependent.SessionTarget.StepID == id { return fmt.Errorf("workflow step %q is the session target of %q", id, dependent.ID) } } 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 { // The workflow is authorized here; the individual step IDs are authorized // by the service, which is where the reorder actually resolves them. if err := c.svc.AuthorizeWorkflow(ctx, req.WorkflowID); err != nil { return err } if err := c.svc.EnsureWorkflowMutable(ctx, req.WorkflowID); err != nil { return err } positions := make(map[string]int, len(req.StepIDs)) for position, stepID := range req.StepIDs { positions[stepID] = position } if err := c.validateIncomingSessionTargets(ctx, req.WorkflowID, nil, positions); 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. // The export/import trio authorizes inside the service: the MCP config tools // call those methods directly instead of coming through this controller. 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) }