feat: import export workflows (#244)
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
editorservice "github.com/kandev/kandev/internal/editors/service"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
promptservice "github.com/kandev/kandev/internal/prompts/service"
|
||||
taskmodels "github.com/kandev/kandev/internal/task/models"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
userservice "github.com/kandev/kandev/internal/user/service"
|
||||
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
||||
@@ -51,6 +52,9 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
|
||||
// Wire start step resolver to task service for CreateTask
|
||||
taskSvc.SetStartStepResolver(&startStepResolverAdapter{svc: workflowSvc})
|
||||
|
||||
// Wire workflow provider to workflow service for export/import
|
||||
workflowSvc.SetWorkflowProvider(&workflowProviderAdapter{svc: taskSvc})
|
||||
|
||||
return &Services{
|
||||
Task: taskSvc,
|
||||
User: userSvc,
|
||||
@@ -91,3 +95,27 @@ func (a *startStepResolverAdapter) ResolveStartStep(ctx context.Context, workflo
|
||||
}
|
||||
return step.ID, nil
|
||||
}
|
||||
|
||||
// workflowProviderAdapter adapts task service to workflow service's WorkflowProvider interface.
|
||||
type workflowProviderAdapter struct {
|
||||
svc *taskservice.Service
|
||||
}
|
||||
|
||||
// ListWorkflows implements workflowservice.WorkflowProvider.
|
||||
func (a *workflowProviderAdapter) ListWorkflows(ctx context.Context, workspaceID string) ([]*taskmodels.Workflow, error) {
|
||||
return a.svc.ListWorkflows(ctx, workspaceID)
|
||||
}
|
||||
|
||||
// GetWorkflow implements workflowservice.WorkflowProvider.
|
||||
func (a *workflowProviderAdapter) GetWorkflow(ctx context.Context, id string) (*taskmodels.Workflow, error) {
|
||||
return a.svc.GetWorkflow(ctx, id)
|
||||
}
|
||||
|
||||
// CreateWorkflow implements workflowservice.WorkflowProvider.
|
||||
func (a *workflowProviderAdapter) CreateWorkflow(ctx context.Context, workspaceID, name, description string) (*taskmodels.Workflow, error) {
|
||||
return a.svc.CreateWorkflow(ctx, &taskservice.CreateWorkflowRequest{
|
||||
WorkspaceID: workspaceID,
|
||||
Name: name,
|
||||
Description: description,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -198,3 +198,26 @@ func (c *Controller) ListHistoryBySession(ctx context.Context, req ListHistoryRe
|
||||
}
|
||||
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 all workflows for a workspace.
|
||||
func (c *Controller) ExportWorkflows(ctx context.Context, workspaceID string) (*models.WorkflowExport, error) {
|
||||
return c.svc.ExportWorkflows(ctx, workspaceID)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,11 @@ func (h *Handlers) registerHTTP(router *gin.Engine) {
|
||||
api.DELETE("/workflow/steps/:id", h.httpDeleteStep)
|
||||
api.PUT("/workflows/:id/workflow/steps/reorder", h.httpReorderSteps)
|
||||
|
||||
// Export/Import routes
|
||||
api.GET("/workflows/:id/export", h.httpExportWorkflow)
|
||||
api.GET("/workspaces/:id/workflows/export", h.httpExportWorkflows)
|
||||
api.POST("/workspaces/:id/workflows/import", h.httpImportWorkflows)
|
||||
|
||||
// History routes
|
||||
api.GET("/sessions/:id/workflow/history", h.httpListHistoryBySession)
|
||||
}
|
||||
@@ -208,6 +213,44 @@ func (h *Handlers) httpListHistoryBySession(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// HTTP handlers - Export/Import
|
||||
|
||||
func (h *Handlers) httpExportWorkflow(c *gin.Context) {
|
||||
resp, err := h.controller.ExportWorkflow(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
h.logger.Error("failed to export workflow", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to export workflow"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpExportWorkflows(c *gin.Context) {
|
||||
resp, err := h.controller.ExportWorkflows(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
h.logger.Error("failed to export workflows", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to export workflows"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpImportWorkflows(c *gin.Context) {
|
||||
var req controller.ImportWorkflowsRequest
|
||||
if err := c.ShouldBindJSON(&req.Data); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
req.WorkspaceID = c.Param("id")
|
||||
resp, err := h.controller.ImportWorkflows(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to import workflows", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// WS handlers - Templates
|
||||
|
||||
func (h *Handlers) wsListTemplates(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
|
||||
taskmodels "github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
const (
|
||||
ExportVersion = 1
|
||||
ExportType = "kandev_workflow"
|
||||
)
|
||||
|
||||
// WorkflowExport is the portable JSON format for sharing workflows.
|
||||
type WorkflowExport struct {
|
||||
Version int `json:"version"`
|
||||
Type string `json:"type"`
|
||||
Workflows []WorkflowPortable `json:"workflows"`
|
||||
}
|
||||
|
||||
// WorkflowPortable is a workflow without instance-specific fields (IDs, timestamps).
|
||||
type WorkflowPortable struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Steps []StepPortable `json:"steps"`
|
||||
}
|
||||
|
||||
// StepPortable is a workflow step without instance-specific fields.
|
||||
type StepPortable struct {
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
Color string `json:"color"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
Events StepEvents `json:"events"`
|
||||
IsStartStep bool `json:"is_start_step"`
|
||||
AllowManualMove bool `json:"allow_manual_move"`
|
||||
AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty"`
|
||||
}
|
||||
|
||||
// BuildWorkflowExport builds a portable WorkflowExport from domain models.
|
||||
// stepsByWorkflow maps workflow ID → its steps (ordered by position).
|
||||
func BuildWorkflowExport(workflows []*taskmodels.Workflow, stepsByWorkflow map[string][]*WorkflowStep) *WorkflowExport {
|
||||
portable := make([]WorkflowPortable, 0, len(workflows))
|
||||
for _, wf := range workflows {
|
||||
steps := stepsByWorkflow[wf.ID]
|
||||
portable = append(portable, buildWorkflowPortable(wf, steps))
|
||||
}
|
||||
return &WorkflowExport{
|
||||
Version: ExportVersion,
|
||||
Type: ExportType,
|
||||
Workflows: portable,
|
||||
}
|
||||
}
|
||||
|
||||
func buildWorkflowPortable(wf *taskmodels.Workflow, steps []*WorkflowStep) WorkflowPortable {
|
||||
portableSteps := make([]StepPortable, 0, len(steps))
|
||||
// Build step ID → position map for converting move_to_step references.
|
||||
idToPos := make(map[string]int, len(steps))
|
||||
for _, s := range steps {
|
||||
idToPos[s.ID] = s.Position
|
||||
}
|
||||
for _, s := range steps {
|
||||
portableSteps = append(portableSteps, StepPortable{
|
||||
Name: s.Name,
|
||||
Position: s.Position,
|
||||
Color: s.Color,
|
||||
Prompt: s.Prompt,
|
||||
Events: convertStepIDToPosition(s.Events, idToPos),
|
||||
IsStartStep: s.IsStartStep,
|
||||
AllowManualMove: s.AllowManualMove,
|
||||
AutoArchiveAfterHours: s.AutoArchiveAfterHours,
|
||||
})
|
||||
}
|
||||
return WorkflowPortable{
|
||||
Name: wf.Name,
|
||||
Description: wf.Description,
|
||||
Steps: portableSteps,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks that the export data is well-formed.
|
||||
func (e *WorkflowExport) Validate() error {
|
||||
if e.Version != ExportVersion {
|
||||
return fmt.Errorf("unsupported export version: %d (expected %d)", e.Version, ExportVersion)
|
||||
}
|
||||
if e.Type != ExportType {
|
||||
return fmt.Errorf("unsupported export type: %q (expected %q)", e.Type, ExportType)
|
||||
}
|
||||
if len(e.Workflows) == 0 {
|
||||
return fmt.Errorf("export contains no workflows")
|
||||
}
|
||||
for i, wf := range e.Workflows {
|
||||
if wf.Name == "" {
|
||||
return fmt.Errorf("workflow %d: name is required", i)
|
||||
}
|
||||
positions := make(map[int]bool, len(wf.Steps))
|
||||
for j, step := range wf.Steps {
|
||||
if step.Name == "" {
|
||||
return fmt.Errorf("workflow %d step %d: name is required", i, j)
|
||||
}
|
||||
if positions[step.Position] {
|
||||
return fmt.Errorf("workflow %d: duplicate step position %d", i, step.Position)
|
||||
}
|
||||
positions[step.Position] = true
|
||||
}
|
||||
// Validate that move_to_step references point to valid positions.
|
||||
if err := validateStepPositionRefs(wf.Steps, positions); err != nil {
|
||||
return fmt.Errorf("workflow %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStepPositionRefs(steps []StepPortable, validPositions map[int]bool) error {
|
||||
for _, step := range steps {
|
||||
for _, a := range step.Events.OnTurnStart {
|
||||
if a.Type == OnTurnStartMoveToStep {
|
||||
if err := checkPositionRef(a.Config, validPositions); err != nil {
|
||||
return fmt.Errorf("step %q on_turn_start: %w", step.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, a := range step.Events.OnTurnComplete {
|
||||
if a.Type == OnTurnCompleteMoveToStep {
|
||||
if err := checkPositionRef(a.Config, validPositions); err != nil {
|
||||
return fmt.Errorf("step %q on_turn_complete: %w", step.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkPositionRef(config map[string]any, validPositions map[int]bool) error {
|
||||
if config == nil {
|
||||
return fmt.Errorf("move_to_step action missing config")
|
||||
}
|
||||
pos, exists := config["step_position"]
|
||||
if !exists {
|
||||
return fmt.Errorf("move_to_step action missing step_position")
|
||||
}
|
||||
posInt, ok := toInt(pos)
|
||||
if !ok {
|
||||
return fmt.Errorf("step_position has unexpected type %T", pos)
|
||||
}
|
||||
if !validPositions[posInt] {
|
||||
return fmt.Errorf("step_position %d does not match any step", posInt)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertStepIDToPosition rewrites move_to_step events: step_id → step_position.
|
||||
func convertStepIDToPosition(events StepEvents, idToPos map[string]int) StepEvents {
|
||||
return remapStepEvents(events, "step_id", "step_position", func(v any) (any, bool) {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
pos, found := idToPos[s]
|
||||
return pos, found
|
||||
})
|
||||
}
|
||||
|
||||
// ConvertPositionToStepID rewrites move_to_step events: step_position → step_id.
|
||||
// posToID maps position → new step ID.
|
||||
func ConvertPositionToStepID(events StepEvents, posToID map[int]string) StepEvents {
|
||||
return remapStepEvents(events, "step_position", "step_id", func(v any) (any, bool) {
|
||||
pos, ok := toInt(v)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
id, found := posToID[pos]
|
||||
return id, found
|
||||
})
|
||||
}
|
||||
|
||||
// remapStepEvents rewrites move_to_step config in OnTurnStart and OnTurnComplete actions,
|
||||
// replacing fromKey with toKey using the provided lookup function.
|
||||
func remapStepEvents(events StepEvents, fromKey, toKey string, lookup func(any) (any, bool)) StepEvents {
|
||||
result := StepEvents{
|
||||
OnEnter: append([]OnEnterAction{}, events.OnEnter...),
|
||||
OnExit: append([]OnExitAction{}, events.OnExit...),
|
||||
}
|
||||
for _, a := range events.OnTurnStart {
|
||||
if a.Type == OnTurnStartMoveToStep {
|
||||
if cfg, ok := remapConfigKey(a.Config, fromKey, toKey, lookup); ok {
|
||||
a = OnTurnStartAction{Type: a.Type, Config: cfg}
|
||||
}
|
||||
}
|
||||
result.OnTurnStart = append(result.OnTurnStart, a)
|
||||
}
|
||||
for _, a := range events.OnTurnComplete {
|
||||
if a.Type == OnTurnCompleteMoveToStep {
|
||||
if cfg, ok := remapConfigKey(a.Config, fromKey, toKey, lookup); ok {
|
||||
a = OnTurnCompleteAction{Type: a.Type, Config: cfg}
|
||||
}
|
||||
}
|
||||
result.OnTurnComplete = append(result.OnTurnComplete, a)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// remapConfigKey copies config, replaces fromKey with toKey using lookup.
|
||||
func remapConfigKey(config map[string]any, fromKey, toKey string, lookup func(any) (any, bool)) (map[string]any, bool) {
|
||||
if config == nil {
|
||||
return nil, false
|
||||
}
|
||||
val, exists := config[fromKey]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
newVal, found := lookup(val)
|
||||
if !found {
|
||||
return nil, false
|
||||
}
|
||||
cfg := make(map[string]any, len(config))
|
||||
maps.Copy(cfg, config)
|
||||
delete(cfg, fromKey)
|
||||
cfg[toKey] = newVal
|
||||
return cfg, true
|
||||
}
|
||||
|
||||
func toInt(v any) (int, bool) {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return int(val), true
|
||||
case int:
|
||||
return val, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
taskmodels "github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
func TestBuildWorkflowExport(t *testing.T) {
|
||||
t.Run("converts step IDs to positions", func(t *testing.T) {
|
||||
wf := &taskmodels.Workflow{ID: "wf-1", Name: "My Workflow", Description: "desc"}
|
||||
steps := []*WorkflowStep{
|
||||
{ID: "step-a", Name: "Todo", Position: 0, Color: "blue"},
|
||||
{
|
||||
ID: "step-b", Name: "In Progress", Position: 1, Color: "yellow",
|
||||
Events: StepEvents{
|
||||
OnTurnComplete: []OnTurnCompleteAction{
|
||||
{Type: OnTurnCompleteMoveToStep, Config: map[string]any{"step_id": "step-a"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
stepMap := map[string][]*WorkflowStep{"wf-1": steps}
|
||||
|
||||
export := BuildWorkflowExport([]*taskmodels.Workflow{wf}, stepMap)
|
||||
|
||||
require.Equal(t, ExportVersion, export.Version)
|
||||
require.Equal(t, ExportType, export.Type)
|
||||
require.Len(t, export.Workflows, 1)
|
||||
|
||||
pw := export.Workflows[0]
|
||||
assert.Equal(t, "My Workflow", pw.Name)
|
||||
assert.Equal(t, "desc", pw.Description)
|
||||
require.Len(t, pw.Steps, 2)
|
||||
|
||||
// The move_to_step should reference position 0 (step-a's position), not step-a ID.
|
||||
action := pw.Steps[1].Events.OnTurnComplete[0]
|
||||
assert.Equal(t, OnTurnCompleteMoveToStep, action.Type)
|
||||
assert.Equal(t, 0, action.Config["step_position"])
|
||||
assert.Nil(t, action.Config["step_id"])
|
||||
})
|
||||
|
||||
t.Run("preserves non-move events", func(t *testing.T) {
|
||||
wf := &taskmodels.Workflow{ID: "wf-1", Name: "WF"}
|
||||
steps := []*WorkflowStep{
|
||||
{
|
||||
ID: "step-a", Name: "Step", Position: 0,
|
||||
Events: StepEvents{
|
||||
OnEnter: []OnEnterAction{{Type: OnEnterAutoStartAgent}},
|
||||
OnExit: []OnExitAction{{Type: OnExitDisablePlanMode}},
|
||||
OnTurnStart: []OnTurnStartAction{
|
||||
{Type: OnTurnStartMoveToNext},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
export := BuildWorkflowExport([]*taskmodels.Workflow{wf}, map[string][]*WorkflowStep{"wf-1": steps})
|
||||
|
||||
sp := export.Workflows[0].Steps[0]
|
||||
assert.Len(t, sp.Events.OnEnter, 1)
|
||||
assert.Equal(t, OnEnterAutoStartAgent, sp.Events.OnEnter[0].Type)
|
||||
assert.Len(t, sp.Events.OnExit, 1)
|
||||
assert.Len(t, sp.Events.OnTurnStart, 1)
|
||||
assert.Equal(t, OnTurnStartMoveToNext, sp.Events.OnTurnStart[0].Type)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
validExport := func() *WorkflowExport {
|
||||
return &WorkflowExport{
|
||||
Version: ExportVersion,
|
||||
Type: ExportType,
|
||||
Workflows: []WorkflowPortable{
|
||||
{
|
||||
Name: "Test",
|
||||
Steps: []StepPortable{
|
||||
{Name: "Todo", Position: 0, Color: "blue"},
|
||||
{Name: "Done", Position: 1, Color: "green"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("valid export passes", func(t *testing.T) {
|
||||
assert.NoError(t, validExport().Validate())
|
||||
})
|
||||
|
||||
t.Run("wrong version fails", func(t *testing.T) {
|
||||
e := validExport()
|
||||
e.Version = 99
|
||||
err := e.Validate()
|
||||
assert.ErrorContains(t, err, "unsupported export version")
|
||||
})
|
||||
|
||||
t.Run("wrong type fails", func(t *testing.T) {
|
||||
e := validExport()
|
||||
e.Type = "wrong"
|
||||
err := e.Validate()
|
||||
assert.ErrorContains(t, err, "unsupported export type")
|
||||
})
|
||||
|
||||
t.Run("empty workflows fails", func(t *testing.T) {
|
||||
e := validExport()
|
||||
e.Workflows = nil
|
||||
assert.ErrorContains(t, e.Validate(), "no workflows")
|
||||
})
|
||||
|
||||
t.Run("empty workflow name fails", func(t *testing.T) {
|
||||
e := validExport()
|
||||
e.Workflows[0].Name = ""
|
||||
assert.ErrorContains(t, e.Validate(), "name is required")
|
||||
})
|
||||
|
||||
t.Run("empty step name fails", func(t *testing.T) {
|
||||
e := validExport()
|
||||
e.Workflows[0].Steps[0].Name = ""
|
||||
assert.ErrorContains(t, e.Validate(), "step 0: name is required")
|
||||
})
|
||||
|
||||
t.Run("duplicate positions fails", func(t *testing.T) {
|
||||
e := validExport()
|
||||
e.Workflows[0].Steps[1].Position = 0
|
||||
assert.ErrorContains(t, e.Validate(), "duplicate step position 0")
|
||||
})
|
||||
|
||||
t.Run("valid move_to_step position ref passes", func(t *testing.T) {
|
||||
e := validExport()
|
||||
e.Workflows[0].Steps[0].Events = StepEvents{
|
||||
OnTurnComplete: []OnTurnCompleteAction{
|
||||
{Type: OnTurnCompleteMoveToStep, Config: map[string]any{"step_position": 1}},
|
||||
},
|
||||
}
|
||||
assert.NoError(t, e.Validate())
|
||||
})
|
||||
|
||||
t.Run("invalid move_to_step position ref fails", func(t *testing.T) {
|
||||
e := validExport()
|
||||
e.Workflows[0].Steps[0].Events = StepEvents{
|
||||
OnTurnComplete: []OnTurnCompleteAction{
|
||||
{Type: OnTurnCompleteMoveToStep, Config: map[string]any{"step_position": 99}},
|
||||
},
|
||||
}
|
||||
assert.ErrorContains(t, e.Validate(), "does not match any step")
|
||||
})
|
||||
|
||||
t.Run("missing config on move_to_step fails", func(t *testing.T) {
|
||||
e := validExport()
|
||||
e.Workflows[0].Steps[0].Events = StepEvents{
|
||||
OnTurnStart: []OnTurnStartAction{
|
||||
{Type: OnTurnStartMoveToStep, Config: nil},
|
||||
},
|
||||
}
|
||||
assert.ErrorContains(t, e.Validate(), "missing config")
|
||||
})
|
||||
|
||||
t.Run("missing step_position key fails", func(t *testing.T) {
|
||||
e := validExport()
|
||||
e.Workflows[0].Steps[0].Events = StepEvents{
|
||||
OnTurnStart: []OnTurnStartAction{
|
||||
{Type: OnTurnStartMoveToStep, Config: map[string]any{"other": "val"}},
|
||||
},
|
||||
}
|
||||
assert.ErrorContains(t, e.Validate(), "missing step_position")
|
||||
})
|
||||
|
||||
t.Run("float64 position ref passes (JSON unmarshal)", func(t *testing.T) {
|
||||
e := validExport()
|
||||
e.Workflows[0].Steps[0].Events = StepEvents{
|
||||
OnTurnComplete: []OnTurnCompleteAction{
|
||||
{Type: OnTurnCompleteMoveToStep, Config: map[string]any{"step_position": float64(1)}},
|
||||
},
|
||||
}
|
||||
assert.NoError(t, e.Validate())
|
||||
})
|
||||
|
||||
t.Run("invalid position type fails", func(t *testing.T) {
|
||||
e := validExport()
|
||||
e.Workflows[0].Steps[0].Events = StepEvents{
|
||||
OnTurnComplete: []OnTurnCompleteAction{
|
||||
{Type: OnTurnCompleteMoveToStep, Config: map[string]any{"step_position": "not-a-number"}},
|
||||
},
|
||||
}
|
||||
assert.ErrorContains(t, e.Validate(), "unexpected type")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertStepIDToPosition(t *testing.T) {
|
||||
idToPos := map[string]int{"step-a": 0, "step-b": 1}
|
||||
|
||||
t.Run("rewrites step_id to step_position in OnTurnStart", func(t *testing.T) {
|
||||
events := StepEvents{
|
||||
OnTurnStart: []OnTurnStartAction{
|
||||
{Type: OnTurnStartMoveToStep, Config: map[string]any{"step_id": "step-b"}},
|
||||
},
|
||||
}
|
||||
result := convertStepIDToPosition(events, idToPos)
|
||||
require.Len(t, result.OnTurnStart, 1)
|
||||
assert.Equal(t, 1, result.OnTurnStart[0].Config["step_position"])
|
||||
assert.Nil(t, result.OnTurnStart[0].Config["step_id"])
|
||||
})
|
||||
|
||||
t.Run("rewrites step_id to step_position in OnTurnComplete", func(t *testing.T) {
|
||||
events := StepEvents{
|
||||
OnTurnComplete: []OnTurnCompleteAction{
|
||||
{Type: OnTurnCompleteMoveToStep, Config: map[string]any{"step_id": "step-a"}},
|
||||
},
|
||||
}
|
||||
result := convertStepIDToPosition(events, idToPos)
|
||||
require.Len(t, result.OnTurnComplete, 1)
|
||||
assert.Equal(t, 0, result.OnTurnComplete[0].Config["step_position"])
|
||||
assert.Nil(t, result.OnTurnComplete[0].Config["step_id"])
|
||||
})
|
||||
|
||||
t.Run("preserves non-move actions unchanged", func(t *testing.T) {
|
||||
events := StepEvents{
|
||||
OnTurnStart: []OnTurnStartAction{{Type: OnTurnStartMoveToNext}},
|
||||
OnTurnComplete: []OnTurnCompleteAction{{Type: OnTurnCompleteMoveToNext}},
|
||||
OnEnter: []OnEnterAction{{Type: OnEnterAutoStartAgent}},
|
||||
OnExit: []OnExitAction{{Type: OnExitDisablePlanMode}},
|
||||
}
|
||||
result := convertStepIDToPosition(events, idToPos)
|
||||
assert.Len(t, result.OnTurnStart, 1)
|
||||
assert.Equal(t, OnTurnStartMoveToNext, result.OnTurnStart[0].Type)
|
||||
assert.Len(t, result.OnTurnComplete, 1)
|
||||
assert.Len(t, result.OnEnter, 1)
|
||||
assert.Len(t, result.OnExit, 1)
|
||||
})
|
||||
|
||||
t.Run("unknown step_id left unchanged", func(t *testing.T) {
|
||||
events := StepEvents{
|
||||
OnTurnStart: []OnTurnStartAction{
|
||||
{Type: OnTurnStartMoveToStep, Config: map[string]any{"step_id": "unknown"}},
|
||||
},
|
||||
}
|
||||
result := convertStepIDToPosition(events, idToPos)
|
||||
assert.Equal(t, "unknown", result.OnTurnStart[0].Config["step_id"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertPositionToStepID(t *testing.T) {
|
||||
posToID := map[int]string{0: "new-a", 1: "new-b"}
|
||||
|
||||
t.Run("rewrites step_position to step_id in OnTurnStart", func(t *testing.T) {
|
||||
events := StepEvents{
|
||||
OnTurnStart: []OnTurnStartAction{
|
||||
{Type: OnTurnStartMoveToStep, Config: map[string]any{"step_position": 1}},
|
||||
},
|
||||
}
|
||||
result := ConvertPositionToStepID(events, posToID)
|
||||
require.Len(t, result.OnTurnStart, 1)
|
||||
assert.Equal(t, "new-b", result.OnTurnStart[0].Config["step_id"])
|
||||
assert.Nil(t, result.OnTurnStart[0].Config["step_position"])
|
||||
})
|
||||
|
||||
t.Run("rewrites step_position to step_id in OnTurnComplete", func(t *testing.T) {
|
||||
events := StepEvents{
|
||||
OnTurnComplete: []OnTurnCompleteAction{
|
||||
{Type: OnTurnCompleteMoveToStep, Config: map[string]any{"step_position": 0}},
|
||||
},
|
||||
}
|
||||
result := ConvertPositionToStepID(events, posToID)
|
||||
require.Len(t, result.OnTurnComplete, 1)
|
||||
assert.Equal(t, "new-a", result.OnTurnComplete[0].Config["step_id"])
|
||||
assert.Nil(t, result.OnTurnComplete[0].Config["step_position"])
|
||||
})
|
||||
|
||||
t.Run("handles float64 position (JSON unmarshal)", func(t *testing.T) {
|
||||
events := StepEvents{
|
||||
OnTurnStart: []OnTurnStartAction{
|
||||
{Type: OnTurnStartMoveToStep, Config: map[string]any{"step_position": float64(0)}},
|
||||
},
|
||||
}
|
||||
result := ConvertPositionToStepID(events, posToID)
|
||||
assert.Equal(t, "new-a", result.OnTurnStart[0].Config["step_id"])
|
||||
})
|
||||
|
||||
t.Run("unknown position left unchanged", func(t *testing.T) {
|
||||
events := StepEvents{
|
||||
OnTurnComplete: []OnTurnCompleteAction{
|
||||
{Type: OnTurnCompleteMoveToStep, Config: map[string]any{"step_position": 99}},
|
||||
},
|
||||
}
|
||||
result := ConvertPositionToStepID(events, posToID)
|
||||
assert.Equal(t, 99, result.OnTurnComplete[0].Config["step_position"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
t.Run("export then import preserves events", func(t *testing.T) {
|
||||
// Build domain steps with step_id references.
|
||||
steps := []*WorkflowStep{
|
||||
{ID: "orig-a", Name: "Backlog", Position: 0, Color: "gray"},
|
||||
{
|
||||
ID: "orig-b", Name: "In Progress", Position: 1, Color: "blue",
|
||||
Events: StepEvents{
|
||||
OnTurnComplete: []OnTurnCompleteAction{
|
||||
{Type: OnTurnCompleteMoveToStep, Config: map[string]any{"step_id": "orig-c"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "orig-c", Name: "Done", Position: 2, Color: "green",
|
||||
Events: StepEvents{
|
||||
OnTurnStart: []OnTurnStartAction{
|
||||
{Type: OnTurnStartMoveToStep, Config: map[string]any{"step_id": "orig-b"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
wf := &taskmodels.Workflow{ID: "wf-1", Name: "Pipeline"}
|
||||
export := BuildWorkflowExport([]*taskmodels.Workflow{wf}, map[string][]*WorkflowStep{"wf-1": steps})
|
||||
|
||||
require.NoError(t, export.Validate())
|
||||
|
||||
// Simulate import: assign new IDs by position.
|
||||
posToID := map[int]string{0: "new-a", 1: "new-b", 2: "new-c"}
|
||||
for _, sp := range export.Workflows[0].Steps {
|
||||
imported := ConvertPositionToStepID(sp.Events, posToID)
|
||||
|
||||
switch sp.Name {
|
||||
case "In Progress":
|
||||
require.Len(t, imported.OnTurnComplete, 1)
|
||||
assert.Equal(t, "new-c", imported.OnTurnComplete[0].Config["step_id"],
|
||||
"In Progress should now reference new Done ID")
|
||||
case "Done":
|
||||
require.Len(t, imported.OnTurnStart, 1)
|
||||
assert.Equal(t, "new-b", imported.OnTurnStart[0].Config["step_id"],
|
||||
"Done should now reference new In Progress ID")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestToInt(t *testing.T) {
|
||||
t.Run("float64", func(t *testing.T) {
|
||||
v, ok := toInt(float64(42))
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 42, v)
|
||||
})
|
||||
t.Run("int", func(t *testing.T) {
|
||||
v, ok := toInt(7)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 7, v)
|
||||
})
|
||||
t.Run("string returns false", func(t *testing.T) {
|
||||
_, ok := toInt("nope")
|
||||
assert.False(t, ok)
|
||||
})
|
||||
t.Run("nil returns false", func(t *testing.T) {
|
||||
_, ok := toInt(nil)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
@@ -9,14 +9,28 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"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) ([]*taskmodels.Workflow, error)
|
||||
GetWorkflow(ctx context.Context, id string) (*taskmodels.Workflow, error)
|
||||
CreateWorkflow(ctx context.Context, workspaceID, name, description string) (*taskmodels.Workflow, error)
|
||||
}
|
||||
|
||||
// Service provides workflow business logic
|
||||
type Service struct {
|
||||
repo *repository.Repository
|
||||
logger *logger.Logger
|
||||
repo *repository.Repository
|
||||
logger *logger.Logger
|
||||
workflowProvider WorkflowProvider
|
||||
}
|
||||
|
||||
// SetWorkflowProvider wires the workflow provider (set during service init to break circular deps).
|
||||
func (s *Service) SetWorkflowProvider(wp WorkflowProvider) {
|
||||
s.workflowProvider = wp
|
||||
}
|
||||
|
||||
// NewService creates a new workflow service
|
||||
@@ -331,3 +345,111 @@ func (s *Service) ListHistoryBySession(ctx context.Context, sessionID string) ([
|
||||
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), nil
|
||||
}
|
||||
|
||||
// ExportWorkflows exports all workflows for a workspace.
|
||||
func (s *Service) ExportWorkflows(ctx context.Context, workspaceID string) (*models.WorkflowExport, error) {
|
||||
workflows, err := s.workflowProvider.ListWorkflows(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list workflows: %w", err)
|
||||
}
|
||||
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), nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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,
|
||||
AllowManualMove: sp.AllowManualMove,
|
||||
AutoArchiveAfterHours: sp.AutoArchiveAfterHours,
|
||||
}
|
||||
if err := s.repo.CreateStep(ctx, step); err != nil {
|
||||
return fmt.Errorf("create step %q: %w", sp.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"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"
|
||||
)
|
||||
@@ -43,6 +45,55 @@ func insertWorkflow(t *testing.T, db *sqlx.DB, id, name string) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// mockWorkflowProvider implements WorkflowProvider with in-memory state for tests.
|
||||
type mockWorkflowProvider struct {
|
||||
workflows []*taskmodels.Workflow
|
||||
}
|
||||
|
||||
func (m *mockWorkflowProvider) ListWorkflows(_ context.Context, workspaceID string) ([]*taskmodels.Workflow, error) {
|
||||
var result []*taskmodels.Workflow
|
||||
for _, wf := range m.workflows {
|
||||
if wf.WorkspaceID == workspaceID {
|
||||
result = append(result, wf)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockWorkflowProvider) GetWorkflow(_ context.Context, id string) (*taskmodels.Workflow, error) {
|
||||
for _, wf := range m.workflows {
|
||||
if wf.ID == id {
|
||||
return wf, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("workflow %s not found", id)
|
||||
}
|
||||
|
||||
func (m *mockWorkflowProvider) CreateWorkflow(_ context.Context, workspaceID, name, description string) (*taskmodels.Workflow, error) {
|
||||
now := time.Now().UTC()
|
||||
wf := &taskmodels.Workflow{
|
||||
ID: "imported-" + name,
|
||||
WorkspaceID: workspaceID,
|
||||
Name: name,
|
||||
Description: description,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
m.workflows = append(m.workflows, wf)
|
||||
return wf, nil
|
||||
}
|
||||
|
||||
func (m *mockWorkflowProvider) addWorkflow(id, workspaceID, name string) {
|
||||
now := time.Now().UTC()
|
||||
m.workflows = append(m.workflows, &taskmodels.Workflow{
|
||||
ID: id,
|
||||
WorkspaceID: workspaceID,
|
||||
Name: name,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
func createStep(t *testing.T, svc *Service, step *models.WorkflowStep) {
|
||||
err := svc.CreateStep(context.Background(), step)
|
||||
require.NoError(t, err)
|
||||
@@ -310,3 +361,189 @@ func findStepByName(steps []*models.WorkflowStep, name string) *models.WorkflowS
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupTestServiceWithProvider(t *testing.T) (*Service, *sqlx.DB, *mockWorkflowProvider) {
|
||||
svc, db := setupTestService(t)
|
||||
mock := &mockWorkflowProvider{}
|
||||
svc.SetWorkflowProvider(mock)
|
||||
return svc, db, mock
|
||||
}
|
||||
|
||||
func TestExportWorkflow(t *testing.T) {
|
||||
t.Run("exports single workflow with steps", func(t *testing.T) {
|
||||
svc, _, mock := setupTestServiceWithProvider(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mock.addWorkflow("wf-1", "", "My Pipeline")
|
||||
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Todo", Position: 0, Color: "gray"})
|
||||
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Done", Position: 1, Color: "green"})
|
||||
|
||||
export, err := svc.ExportWorkflow(ctx, "wf-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, models.ExportVersion, export.Version)
|
||||
assert.Equal(t, models.ExportType, export.Type)
|
||||
require.Len(t, export.Workflows, 1)
|
||||
assert.Equal(t, "My Pipeline", export.Workflows[0].Name)
|
||||
require.Len(t, export.Workflows[0].Steps, 2)
|
||||
assert.Equal(t, "Todo", export.Workflows[0].Steps[0].Name)
|
||||
assert.Equal(t, "Done", export.Workflows[0].Steps[1].Name)
|
||||
})
|
||||
|
||||
t.Run("converts step_id refs to positions", func(t *testing.T) {
|
||||
svc, _, mock := setupTestServiceWithProvider(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mock.addWorkflow("wf-1", "", "Pipeline")
|
||||
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "A", Position: 0, Color: "gray"})
|
||||
|
||||
// Get the created step's ID so we can reference it.
|
||||
steps, err := svc.repo.ListStepsByWorkflow(ctx, "wf-1")
|
||||
require.NoError(t, err)
|
||||
stepAID := steps[0].ID
|
||||
|
||||
createStep(t, svc, &models.WorkflowStep{
|
||||
WorkflowID: "wf-1", Name: "B", Position: 1, Color: "blue",
|
||||
Events: models.StepEvents{
|
||||
OnTurnComplete: []models.OnTurnCompleteAction{
|
||||
{Type: models.OnTurnCompleteMoveToStep, Config: map[string]any{"step_id": stepAID}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export, err := svc.ExportWorkflow(ctx, "wf-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
stepB := export.Workflows[0].Steps[1]
|
||||
require.Len(t, stepB.Events.OnTurnComplete, 1)
|
||||
assert.Equal(t, 0, stepB.Events.OnTurnComplete[0].Config["step_position"])
|
||||
assert.Nil(t, stepB.Events.OnTurnComplete[0].Config["step_id"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestExportWorkflows(t *testing.T) {
|
||||
t.Run("exports all workflows for workspace", func(t *testing.T) {
|
||||
svc, _, mock := setupTestServiceWithProvider(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mock.addWorkflow("wf-1", "ws-1", "Alpha")
|
||||
mock.addWorkflow("wf-2", "ws-1", "Beta")
|
||||
mock.addWorkflow("wf-3", "ws-2", "Other Workspace")
|
||||
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Step1", Position: 0})
|
||||
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-2", Name: "Step2", Position: 0})
|
||||
|
||||
export, err := svc.ExportWorkflows(ctx, "ws-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, export.Workflows, 2)
|
||||
|
||||
names := []string{export.Workflows[0].Name, export.Workflows[1].Name}
|
||||
assert.Contains(t, names, "Alpha")
|
||||
assert.Contains(t, names, "Beta")
|
||||
})
|
||||
}
|
||||
|
||||
func TestImportWorkflows(t *testing.T) {
|
||||
t.Run("imports new workflows", func(t *testing.T) {
|
||||
svc, _, _ := setupTestServiceWithProvider(t)
|
||||
ctx := context.Background()
|
||||
|
||||
export := &models.WorkflowExport{
|
||||
Version: models.ExportVersion,
|
||||
Type: models.ExportType,
|
||||
Workflows: []models.WorkflowPortable{
|
||||
{
|
||||
Name: "Imported WF",
|
||||
Steps: []models.StepPortable{
|
||||
{Name: "Todo", Position: 0, Color: "gray"},
|
||||
{Name: "Done", Position: 1, Color: "green"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ImportWorkflows(ctx, "ws-1", export)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"Imported WF"}, result.Created)
|
||||
assert.Empty(t, result.Skipped)
|
||||
|
||||
// Verify steps were created.
|
||||
steps, err := svc.repo.ListStepsByWorkflow(ctx, "imported-Imported WF")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, steps, 2)
|
||||
})
|
||||
|
||||
t.Run("skips workflows that already exist by name", func(t *testing.T) {
|
||||
svc, _, mock := setupTestServiceWithProvider(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mock.addWorkflow("wf-existing", "ws-1", "Existing")
|
||||
|
||||
export := &models.WorkflowExport{
|
||||
Version: models.ExportVersion,
|
||||
Type: models.ExportType,
|
||||
Workflows: []models.WorkflowPortable{
|
||||
{Name: "Existing", Steps: []models.StepPortable{{Name: "S", Position: 0, Color: "blue"}}},
|
||||
{Name: "Brand New", Steps: []models.StepPortable{{Name: "S", Position: 0, Color: "red"}}},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ImportWorkflows(ctx, "ws-1", export)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"Brand New"}, result.Created)
|
||||
assert.Equal(t, []string{"Existing"}, result.Skipped)
|
||||
})
|
||||
|
||||
t.Run("remaps step_position to step_id on import", func(t *testing.T) {
|
||||
svc, _, _ := setupTestServiceWithProvider(t)
|
||||
ctx := context.Background()
|
||||
|
||||
export := &models.WorkflowExport{
|
||||
Version: models.ExportVersion,
|
||||
Type: models.ExportType,
|
||||
Workflows: []models.WorkflowPortable{
|
||||
{
|
||||
Name: "WithRefs",
|
||||
Steps: []models.StepPortable{
|
||||
{Name: "A", Position: 0, Color: "gray"},
|
||||
{
|
||||
Name: "B", Position: 1, Color: "blue",
|
||||
Events: models.StepEvents{
|
||||
OnTurnComplete: []models.OnTurnCompleteAction{
|
||||
{Type: models.OnTurnCompleteMoveToStep, Config: map[string]any{"step_position": 0}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ImportWorkflows(ctx, "ws-1", export)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.Created, 1)
|
||||
|
||||
// Verify the imported step B references step A's new ID.
|
||||
steps, err := svc.repo.ListStepsByWorkflow(ctx, "imported-WithRefs")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, steps, 2)
|
||||
|
||||
nameToID := map[string]string{}
|
||||
for _, s := range steps {
|
||||
nameToID[s.Name] = s.ID
|
||||
}
|
||||
|
||||
stepB := findStepByName(steps, "B")
|
||||
require.NotNil(t, stepB)
|
||||
require.Len(t, stepB.Events.OnTurnComplete, 1)
|
||||
assert.Equal(t, nameToID["A"], stepB.Events.OnTurnComplete[0].Config["step_id"])
|
||||
assert.Nil(t, stepB.Events.OnTurnComplete[0].Config["step_position"])
|
||||
})
|
||||
|
||||
t.Run("rejects invalid export data", func(t *testing.T) {
|
||||
svc, _, _ := setupTestServiceWithProvider(t)
|
||||
ctx := context.Background()
|
||||
|
||||
export := &models.WorkflowExport{Version: 99, Type: "wrong"}
|
||||
_, err := svc.ImportWorkflows(ctx, "ws-1", export)
|
||||
assert.ErrorContains(t, err, "invalid export data")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
StepEvents,
|
||||
Workspace,
|
||||
WorkflowStep,
|
||||
WorkflowExportData,
|
||||
ImportWorkflowsResult,
|
||||
ListWorkflowTemplatesResponse,
|
||||
WorkflowTemplate,
|
||||
StepDefinition,
|
||||
@@ -431,3 +433,23 @@ export async function moveSessionToStepAction(sessionId: string, stepId: string)
|
||||
body: JSON.stringify({ step_id: stepId }),
|
||||
});
|
||||
}
|
||||
|
||||
// Workflow Export/Import
|
||||
|
||||
export async function exportWorkflowAction(workflowId: string): Promise<WorkflowExportData> {
|
||||
return fetchJson<WorkflowExportData>(`${apiBaseUrl}/api/v1/workflows/${workflowId}/export`);
|
||||
}
|
||||
|
||||
export async function exportAllWorkflowsAction(workspaceId: string): Promise<WorkflowExportData> {
|
||||
return fetchJson<WorkflowExportData>(`${apiBaseUrl}/api/v1/workspaces/${workspaceId}/workflows/export`);
|
||||
}
|
||||
|
||||
export async function importWorkflowsAction(
|
||||
workspaceId: string,
|
||||
data: WorkflowExportData
|
||||
): Promise<ImportWorkflowsResult> {
|
||||
return fetchJson<ImportWorkflowsResult>(`${apiBaseUrl}/api/v1/workspaces/${workspaceId}/workflows/import`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { IconLayoutColumns, IconPlus } from '@tabler/icons-react';
|
||||
import { IconDownload, IconLayoutColumns, IconPlus, IconUpload } from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Card, CardContent } from '@kandev/ui/card';
|
||||
import { Separator } from '@kandev/ui/separator';
|
||||
@@ -17,14 +17,20 @@ import {
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@kandev/ui/dialog';
|
||||
import { Textarea } from '@kandev/ui/textarea';
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { WorkflowCard } from '@/components/settings/workflow-card';
|
||||
import { WorkflowExportDialog } from '@/components/settings/workflow-export-dialog';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import { useWorkflowSettings } from '@/hooks/domains/settings/use-workflow-settings';
|
||||
import { cn, generateUUID } from '@/lib/utils';
|
||||
import {
|
||||
deleteWorkflowAction,
|
||||
updateWorkflowAction,
|
||||
exportAllWorkflowsAction,
|
||||
importWorkflowsAction,
|
||||
} from '@/app/actions/workspaces';
|
||||
import type { Workflow, StepDefinition, WorkflowStep, Workspace, WorkflowTemplate } from '@/lib/types/http';
|
||||
import type { Workflow, StepDefinition, WorkflowStep, Workspace, WorkflowTemplate, WorkflowExportData } from '@/lib/types/http';
|
||||
|
||||
type WorkspaceWorkflowsClientProps = {
|
||||
workspace: Workspace | null;
|
||||
@@ -38,14 +44,25 @@ export function WorkspaceWorkflowsClient({
|
||||
workflowTemplates,
|
||||
}: WorkspaceWorkflowsClientProps) {
|
||||
const router = useRouter();
|
||||
const [workflowItems, setWorkflowItems] = useState<Workflow[]>(workflows);
|
||||
const [savedWorkflowItems, setSavedWorkflowItems] = useState<Workflow[]>(workflows);
|
||||
const { toast } = useToast();
|
||||
const { workflowItems, setWorkflowItems, setSavedWorkflowItems, isWorkflowDirty } =
|
||||
useWorkflowSettings(workflows);
|
||||
|
||||
// Dialog state for creating a new workflow
|
||||
const [isAddWorkflowDialogOpen, setIsAddWorkflowDialogOpen] = useState(false);
|
||||
const [newWorkflowName, setNewWorkflowName] = useState('');
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
||||
|
||||
// Export dialog state
|
||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||
const [exportJson, setExportJson] = useState('');
|
||||
|
||||
// Import dialog state
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||
const [importJson, setImportJson] = useState('');
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const templateStepsById = useMemo(() => {
|
||||
return new Map(
|
||||
workflowTemplates.map((template) => [template.id, template.default_steps ?? []])
|
||||
@@ -77,17 +94,6 @@ export function WorkspaceWorkflowsClient({
|
||||
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);
|
||||
@@ -156,6 +162,60 @@ export function WorkspaceWorkflowsClient({
|
||||
);
|
||||
};
|
||||
|
||||
const handleExportAll = async () => {
|
||||
if (!workspace) return;
|
||||
try {
|
||||
const data = await exportAllWorkflowsAction(workspace.id);
|
||||
setExportJson(JSON.stringify(data, null, 2));
|
||||
setIsExportDialogOpen(true);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to export workflows',
|
||||
description: error instanceof Error ? error.message : 'Request failed',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
setImportJson(event.target?.result as string);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!workspace || !importJson.trim()) return;
|
||||
setImportLoading(true);
|
||||
try {
|
||||
const data = JSON.parse(importJson.trim()) as WorkflowExportData;
|
||||
const result = await importWorkflowsAction(workspace.id, data);
|
||||
const created = result.created ?? [];
|
||||
const skipped = result.skipped ?? [];
|
||||
const parts: string[] = [];
|
||||
if (created.length > 0) parts.push(`Created: ${created.join(', ')}`);
|
||||
if (skipped.length > 0) parts.push(`Skipped (already exist): ${skipped.join(', ')}`);
|
||||
toast({ title: 'Import complete', description: parts.join('. ') });
|
||||
setIsImportDialogOpen(false);
|
||||
setImportJson('');
|
||||
if (created.length > 0) {
|
||||
router.refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to import workflows',
|
||||
description: error instanceof Error ? error.message : 'Invalid JSON',
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!workspace) {
|
||||
return (
|
||||
<div>
|
||||
@@ -192,10 +252,20 @@ export function WorkspaceWorkflowsClient({
|
||||
title="Workflows"
|
||||
description="Create autonomous pipelines with automated transitions or manual workflows where you move tasks yourself"
|
||||
action={
|
||||
<Button size="sm" onClick={handleOpenAddWorkflowDialog} className="cursor-pointer">
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add Workflow
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleExportAll} className="cursor-pointer">
|
||||
<IconDownload className="h-4 w-4 mr-2" />
|
||||
Export All
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setIsImportDialogOpen(true)} className="cursor-pointer">
|
||||
<IconUpload className="h-4 w-4 mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleOpenAddWorkflowDialog} className="cursor-pointer">
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add Workflow
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3">
|
||||
@@ -228,6 +298,51 @@ export function WorkspaceWorkflowsClient({
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<WorkflowExportDialog
|
||||
open={isExportDialogOpen}
|
||||
onOpenChange={setIsExportDialogOpen}
|
||||
title="Export Workflows"
|
||||
json={exportJson}
|
||||
/>
|
||||
|
||||
{/* Import Workflows Dialog */}
|
||||
<Dialog open={isImportDialogOpen} onOpenChange={setIsImportDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Workflows</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Upload JSON file</Label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileUpload}
|
||||
className="block w-full text-sm text-muted-foreground file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground file:cursor-pointer cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Or paste JSON</Label>
|
||||
<Textarea
|
||||
placeholder='{"version": 1, "type": "kandev_workflow", "workflows": [...]}'
|
||||
value={importJson}
|
||||
onChange={(e) => setImportJson(e.target.value)}
|
||||
className="font-mono text-xs max-h-96 overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsImportDialogOpen(false)} className="cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={!importJson.trim() || importLoading} className="cursor-pointer">
|
||||
{importLoading ? 'Importing...' : 'Import'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Create Workflow Dialog */}
|
||||
<Dialog open={isAddWorkflowDialogOpen} onOpenChange={setIsAddWorkflowDialogOpen}>
|
||||
<DialogContent className="sm:w-[900px] sm:max-w-none">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
import { IconDownload, IconTrash } from '@tabler/icons-react';
|
||||
import { Card, CardContent } from '@kandev/ui/card';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import type { Workflow, WorkflowStep } from '@/lib/types/http';
|
||||
import { useRequest } from '@/lib/http/use-request';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import { WorkflowExportDialog } from '@/components/settings/workflow-export-dialog';
|
||||
import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
|
||||
import { WorkflowPipelineEditor } from '@/components/settings/workflow-pipeline-editor';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
getWorkflowTaskCount,
|
||||
getStepTaskCount,
|
||||
bulkMoveTasks,
|
||||
exportWorkflowAction,
|
||||
} from '@/app/actions/workspaces';
|
||||
|
||||
type WorkflowCardProps = {
|
||||
@@ -62,6 +64,8 @@ export function WorkflowCard({
|
||||
}: WorkflowCardProps) {
|
||||
const { toast } = useToast();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [exportJson, setExportJson] = useState('');
|
||||
|
||||
// Workflow deletion migration state
|
||||
const [workflowTaskCount, setWorkflowTaskCount] = useState<number | null>(null);
|
||||
@@ -399,6 +403,20 @@ export function WorkflowCard({
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportWorkflow = async () => {
|
||||
try {
|
||||
const data = await exportWorkflowAction(workflow.id);
|
||||
setExportJson(JSON.stringify(data, null, 2));
|
||||
setExportOpen(true);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to export workflow',
|
||||
description: error instanceof Error ? error.message : 'Request failed',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const activeSaveRequest = isNewWorkflow ? saveNewWorkflowRequest : saveWorkflowRequest;
|
||||
|
||||
const hasTasks = workflowTaskCount !== null && workflowTaskCount > 0;
|
||||
@@ -447,7 +465,18 @@ export function WorkflowCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2">
|
||||
{!isNewWorkflow && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleExportWorkflow}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<IconDownload className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
@@ -538,6 +567,13 @@ export function WorkflowCard({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<WorkflowExportDialog
|
||||
open={exportOpen}
|
||||
onOpenChange={setExportOpen}
|
||||
title="Export Workflow"
|
||||
json={exportJson}
|
||||
/>
|
||||
|
||||
{/* Step Delete Migration Dialog */}
|
||||
<Dialog open={stepDeleteOpen} onOpenChange={setStepDeleteOpen}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { IconCheck, IconCopy } from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kandev/ui/dialog';
|
||||
import { Textarea } from '@kandev/ui/textarea';
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||
|
||||
type WorkflowExportDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
json: string;
|
||||
};
|
||||
|
||||
export function WorkflowExportDialog({ open, onOpenChange, title, json }: WorkflowExportDialogProps) {
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
readOnly
|
||||
value={json}
|
||||
className="font-mono text-xs max-h-96 overflow-y-auto"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="cursor-pointer">
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={() => copy(json)} className="cursor-pointer">
|
||||
{copied ? <IconCheck className="h-4 w-4 mr-2" /> : <IconCopy className="h-4 w-4 mr-2" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import type { Workflow } from '@/lib/types/http';
|
||||
|
||||
/**
|
||||
* Manages workflow list state for the settings page, synced with WS events
|
||||
* from the Zustand store. Supports local edits (dirty tracking) and temp drafts.
|
||||
*/
|
||||
export function useWorkflowSettings(initialWorkflows: Workflow[]) {
|
||||
const storeWorkflows = useAppStore((state) => state.workflows.items);
|
||||
const [workflowItems, setWorkflowItems] = useState<Workflow[]>(initialWorkflows);
|
||||
const [savedWorkflowItems, setSavedWorkflowItems] = useState<Workflow[]>(initialWorkflows);
|
||||
|
||||
// Track all IDs we've ever seen from SSR props so we only add genuinely new ones
|
||||
// (not re-add workflows the user deleted locally).
|
||||
const seenInitialIdsRef = useRef<Set<string>>(new Set(initialWorkflows.map((w) => w.id)));
|
||||
|
||||
// Merge new workflows from SSR props (e.g. after router.refresh() following import).
|
||||
// useState ignores updated initialWorkflows on re-render, so we sync manually.
|
||||
useEffect(() => {
|
||||
const seen = seenInitialIdsRef.current;
|
||||
const newWorkflows = initialWorkflows.filter((w) => !seen.has(w.id));
|
||||
if (newWorkflows.length === 0) return;
|
||||
|
||||
for (const w of newWorkflows) seen.add(w.id);
|
||||
|
||||
setWorkflowItems((prev) => {
|
||||
const localIds = new Set(prev.map((w) => w.id));
|
||||
const toAdd = newWorkflows.filter((w) => !localIds.has(w.id));
|
||||
if (toAdd.length === 0) return prev;
|
||||
return [...prev, ...toAdd];
|
||||
});
|
||||
setSavedWorkflowItems((prev) => {
|
||||
const localIds = new Set(prev.map((w) => w.id));
|
||||
const toAdd = newWorkflows.filter((w) => !localIds.has(w.id));
|
||||
if (toAdd.length === 0) return prev;
|
||||
return [...prev, ...toAdd];
|
||||
});
|
||||
}, [initialWorkflows]);
|
||||
|
||||
// Track which IDs the store has previously reported so we only remove
|
||||
// workflows that were actually deleted via WS, not ones the store never knew about.
|
||||
const prevStoreIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const currentStoreIds = new Set(storeWorkflows.map((w) => w.id));
|
||||
const prevStoreIds = prevStoreIdsRef.current;
|
||||
|
||||
// IDs that were in the store last render but are gone now → actually deleted via WS.
|
||||
const deletedIds = new Set([...prevStoreIds].filter((id) => !currentStoreIds.has(id)));
|
||||
|
||||
prevStoreIdsRef.current = currentStoreIds;
|
||||
|
||||
const newFromStore = (prev: Workflow[]) => {
|
||||
const localIds = new Set(prev.map((w) => w.id));
|
||||
return storeWorkflows.filter((sw) => !localIds.has(sw.id)).map((sw) => storeItemToWorkflow(sw));
|
||||
};
|
||||
|
||||
setWorkflowItems((prev) => {
|
||||
const toAdd = newFromStore(prev);
|
||||
|
||||
// Only remove workflows the store explicitly deleted, keep everything else.
|
||||
const filtered = prev.filter((w) => !deletedIds.has(w.id));
|
||||
const updated = filtered.map((w) => {
|
||||
if (w.id.startsWith('temp-')) return w;
|
||||
const sw = storeWorkflows.find((s) => s.id === w.id);
|
||||
if (sw && sw.name !== w.name) return { ...w, name: sw.name };
|
||||
return w;
|
||||
});
|
||||
|
||||
if (toAdd.length === 0 && updated.length === prev.length && updated.every((w, i) => w === prev[i])) {
|
||||
return prev;
|
||||
}
|
||||
return [...toAdd, ...updated];
|
||||
});
|
||||
|
||||
setSavedWorkflowItems((prev) => {
|
||||
const toAdd = newFromStore(prev);
|
||||
const filtered = prev.filter((w) => !deletedIds.has(w.id));
|
||||
if (toAdd.length === 0 && filtered.length === prev.length) return prev;
|
||||
return [...toAdd, ...filtered];
|
||||
});
|
||||
}, [storeWorkflows]);
|
||||
|
||||
const savedWorkflowsById = useMemo(() => {
|
||||
return new Map(savedWorkflowItems.map((w) => [w.id, w]));
|
||||
}, [savedWorkflowItems]);
|
||||
|
||||
const isWorkflowDirty = (workflow: Workflow) => {
|
||||
const saved = savedWorkflowsById.get(workflow.id);
|
||||
if (!saved) return true;
|
||||
return workflow.name !== saved.name || workflow.description !== saved.description;
|
||||
};
|
||||
|
||||
return {
|
||||
workflowItems,
|
||||
setWorkflowItems,
|
||||
savedWorkflowItems,
|
||||
setSavedWorkflowItems,
|
||||
isWorkflowDirty,
|
||||
};
|
||||
}
|
||||
|
||||
function storeItemToWorkflow(sw: { id: string; workspaceId: string; name: string; description?: string | null }): Workflow {
|
||||
return {
|
||||
id: sw.id,
|
||||
workspace_id: sw.workspaceId,
|
||||
name: sw.name,
|
||||
description: sw.description,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
};
|
||||
}
|
||||
@@ -816,6 +816,35 @@ export type StatsResponse = {
|
||||
git_stats: GitStatsDTO;
|
||||
};
|
||||
|
||||
// Workflow Export/Import types
|
||||
export type WorkflowExportData = {
|
||||
version: number;
|
||||
type: string;
|
||||
workflows: WorkflowPortable[];
|
||||
};
|
||||
|
||||
export type WorkflowPortable = {
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: StepPortable[];
|
||||
};
|
||||
|
||||
export type StepPortable = {
|
||||
name: string;
|
||||
position: number;
|
||||
color: string;
|
||||
prompt?: string;
|
||||
events: StepEvents;
|
||||
is_start_step: boolean;
|
||||
allow_manual_move: boolean;
|
||||
auto_archive_after_hours?: number;
|
||||
};
|
||||
|
||||
export type ImportWorkflowsResult = {
|
||||
created: string[];
|
||||
skipped: string[];
|
||||
};
|
||||
|
||||
// Helper function to check if a step has a specific on_enter action
|
||||
export function stepHasOnEnterAction(step: { events?: StepEvents }, actionType: OnEnterActionType): boolean {
|
||||
return step.events?.on_enter?.some(a => a.type === actionType) ?? false;
|
||||
|
||||
Reference in New Issue
Block a user