From 5a871af788596e521bd2b08bc8c8f792585df473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 17 Feb 2026 23:01:54 +0000 Subject: [PATCH] feat: import export workflows (#244) --- apps/backend/cmd/kandev/services.go | 28 ++ .../workflow/controller/controller.go | 23 ++ .../internal/workflow/handlers/handlers.go | 43 +++ .../internal/workflow/models/export.go | 233 ++++++++++++ .../internal/workflow/models/export_test.go | 357 ++++++++++++++++++ .../internal/workflow/service/service.go | 126 ++++++- .../internal/workflow/service/service_test.go | 237 ++++++++++++ apps/web/app/actions/workspaces.ts | 22 ++ .../workspace/workspace-workflows-client.tsx | 155 +++++++- .../web/components/settings/workflow-card.tsx | 40 +- .../settings/workflow-export-dialog.tsx | 48 +++ .../domains/settings/use-workflow-settings.ts | 115 ++++++ apps/web/lib/types/http.ts | 29 ++ 13 files changed, 1432 insertions(+), 24 deletions(-) create mode 100644 apps/backend/internal/workflow/models/export.go create mode 100644 apps/backend/internal/workflow/models/export_test.go create mode 100644 apps/web/components/settings/workflow-export-dialog.tsx create mode 100644 apps/web/hooks/domains/settings/use-workflow-settings.ts diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index 94328999b6..5a474d80c7 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -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, + }) +} diff --git a/apps/backend/internal/workflow/controller/controller.go b/apps/backend/internal/workflow/controller/controller.go index d3abbb4afb..0c1c4741c1 100644 --- a/apps/backend/internal/workflow/controller/controller.go +++ b/apps/backend/internal/workflow/controller/controller.go @@ -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) +} diff --git a/apps/backend/internal/workflow/handlers/handlers.go b/apps/backend/internal/workflow/handlers/handlers.go index 4c33f19971..e0a11151cc 100644 --- a/apps/backend/internal/workflow/handlers/handlers.go +++ b/apps/backend/internal/workflow/handlers/handlers.go @@ -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) { diff --git a/apps/backend/internal/workflow/models/export.go b/apps/backend/internal/workflow/models/export.go new file mode 100644 index 0000000000..75f57eea1d --- /dev/null +++ b/apps/backend/internal/workflow/models/export.go @@ -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 + } +} diff --git a/apps/backend/internal/workflow/models/export_test.go b/apps/backend/internal/workflow/models/export_test.go new file mode 100644 index 0000000000..1eec1a3b15 --- /dev/null +++ b/apps/backend/internal/workflow/models/export_test.go @@ -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) + }) +} diff --git a/apps/backend/internal/workflow/service/service.go b/apps/backend/internal/workflow/service/service.go index 1c677f2978..eaa502da6f 100644 --- a/apps/backend/internal/workflow/service/service.go +++ b/apps/backend/internal/workflow/service/service.go @@ -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 +} + diff --git a/apps/backend/internal/workflow/service/service_test.go b/apps/backend/internal/workflow/service/service_test.go index e6c3d2888a..00b47fe431 100644 --- a/apps/backend/internal/workflow/service/service_test.go +++ b/apps/backend/internal/workflow/service/service_test.go @@ -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") + }) +} diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index b7704283fc..afad0b4570 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -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 { + return fetchJson(`${apiBaseUrl}/api/v1/workflows/${workflowId}/export`); +} + +export async function exportAllWorkflowsAction(workspaceId: string): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/workspaces/${workspaceId}/workflows/export`); +} + +export async function importWorkflowsAction( + workspaceId: string, + data: WorkflowExportData +): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/workspaces/${workspaceId}/workflows/import`, { + method: 'POST', + body: JSON.stringify(data), + }); +} diff --git a/apps/web/app/settings/workspace/workspace-workflows-client.tsx b/apps/web/app/settings/workspace/workspace-workflows-client.tsx index 225d87450a..83f2f3e23f 100644 --- a/apps/web/app/settings/workspace/workspace-workflows-client.tsx +++ b/apps/web/app/settings/workspace/workspace-workflows-client.tsx @@ -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(workflows); - const [savedWorkflowItems, setSavedWorkflowItems] = useState(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(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(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) => { + 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 (
@@ -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={ - +
+ + + +
} >
@@ -228,6 +298,51 @@ export function WorkspaceWorkflowsClient({
+ + + {/* Import Workflows Dialog */} + + + + Import Workflows + +
+
+ + +
+
+ +