699 lines
25 KiB
Go
699 lines
25 KiB
Go
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"`
|
|
StepProfileBindings []service.ImportProfileBinding `json:"step_profile_bindings,omitempty"`
|
|
}
|
|
|
|
// 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) {
|
|
if req.StepProfileBindings != nil {
|
|
return c.svc.ImportWorkflowsWithBindings(ctx, req.WorkspaceID, req.Data, req.StepProfileBindings)
|
|
}
|
|
return c.svc.ImportWorkflows(ctx, req.WorkspaceID, req.Data)
|
|
}
|
|
|
|
// PreviewImportWorkflows validates a portable document and returns the
|
|
// profile choices required before the browser can persist it.
|
|
func (c *Controller) PreviewImportWorkflows(ctx context.Context, workspaceID string, data *models.WorkflowExport) (*service.ImportProfilePreview, error) {
|
|
return c.svc.PreviewImportWorkflows(ctx, workspaceID, data)
|
|
}
|