feat: import export workflows (#244)

This commit is contained in:
Carlos Florêncio
2026-02-17 23:01:54 +00:00
committed by GitHub
parent 90bec9aaa0
commit 5a871af788
13 changed files with 1432 additions and 24 deletions
+28
View File
@@ -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")
})
}
+22
View File
@@ -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">
+38 -2
View File
@@ -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: '',
};
}
+29
View File
@@ -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;