From 8966a6020b7f970c0ad8feee8ffe2be209339fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 18 Sep 2026 00:34:50 +0100 Subject: [PATCH] feat: add workflow import profile selection (#3771) --- .../backendapp/agent_profile_matcher_test.go | 37 ++ apps/backend/internal/backendapp/services.go | 6 + .../workflow_import_profile_catalog.go | 78 +++ .../workflow/controller/controller.go | 14 +- .../internal/workflow/handlers/handlers.go | 133 +++- .../workflow/handlers/http_handlers_test.go | 233 +++++++ .../workflow/handlers/scope_harness_test.go | 9 + .../handlers/step_reference_scope_test.go | 6 +- .../workflow/handlers/workflow_scope_test.go | 6 +- .../service/import_profile_selection.go | 581 +++++++++++++++++ .../service/import_profile_selection_test.go | 256 ++++++++ .../internal/workflow/service/service.go | 17 +- .../internal/workflow/service/service_test.go | 12 + apps/web/app/actions/workspaces.ts | 25 +- .../workspace/use-workflow-import.test.ts | 277 ++++++++ .../settings/workspace/use-workflow-import.ts | 477 ++++++++++++++ ...import-profile-selection-mobile-header.tsx | 51 ++ ...workflow-import-profile-selection.test.tsx | 323 ++++++++++ .../workflow-import-profile-selection.tsx | 609 ++++++++++++++++++ .../workspace/workspace-workflows-client.tsx | 54 +- .../workspace/workspace-workflows-dialogs.tsx | 88 ++- .../mobile-workflow-import-export.spec.ts | 126 ++++ .../workflow/workflow-import-export.spec.ts | 122 ++++ apps/web/lib/types/http.ts | 51 ++ apps/web/src/locales/en/workflows.json | 17 + apps/web/src/locales/pseudo/workflows.json | 17 + apps/web/src/locales/pt-pt/workflows.json | 17 + apps/web/src/locales/zh-cn/workflows.json | 17 + apps/web/src/locales/zh-hk/workflows.json | 17 + apps/web/src/locales/zh-tw/workflows.json | 17 + .../workflow-import-profile-selection/plan.md | 181 ++++++ .../task-01-import-profile-selection.md | 202 ++++++ docs/public/workflow-import-export.md | 73 ++- .../workflow-import-profile-selection.md | 70 ++ .../workflow-import-profile-selection.md | 184 ++++++ 35 files changed, 4313 insertions(+), 90 deletions(-) create mode 100644 apps/backend/internal/backendapp/workflow_import_profile_catalog.go create mode 100644 apps/backend/internal/workflow/service/import_profile_selection.go create mode 100644 apps/backend/internal/workflow/service/import_profile_selection_test.go create mode 100644 apps/web/app/settings/workspace/use-workflow-import.test.ts create mode 100644 apps/web/app/settings/workspace/use-workflow-import.ts create mode 100644 apps/web/app/settings/workspace/workflow-import-profile-selection-mobile-header.tsx create mode 100644 apps/web/app/settings/workspace/workflow-import-profile-selection.test.tsx create mode 100644 apps/web/app/settings/workspace/workflow-import-profile-selection.tsx create mode 100644 apps/web/e2e/tests/workflow/mobile-workflow-import-export.spec.ts create mode 100644 docs/plans/workflow-import-profile-selection/plan.md create mode 100644 docs/plans/workflow-import-profile-selection/task-01-import-profile-selection.md create mode 100644 docs/specs/tasks/requirements/workflow-import-profile-selection.md create mode 100644 docs/specs/tasks/system-design/workflow-import-profile-selection.md diff --git a/apps/backend/internal/backendapp/agent_profile_matcher_test.go b/apps/backend/internal/backendapp/agent_profile_matcher_test.go index fd4f49a005..847805edff 100644 --- a/apps/backend/internal/backendapp/agent_profile_matcher_test.go +++ b/apps/backend/internal/backendapp/agent_profile_matcher_test.go @@ -2,6 +2,7 @@ package backendapp import ( "context" + "errors" "path/filepath" "testing" @@ -17,6 +18,7 @@ import ( agentsettingsmodels "github.com/kandev/kandev/internal/agent/settings/models" settingsstore "github.com/kandev/kandev/internal/agent/settings/store" "github.com/kandev/kandev/internal/common/logger" + workflowservice "github.com/kandev/kandev/internal/workflow/service" ) // newMatcherTestRepos opens an isolated SQLite-backed agent settings store for @@ -245,3 +247,38 @@ func TestBuildAgentProfileMatcher_MultipleCandidatesLogsDebugWithFields(t *testi assert.Equal(t, int64(2), fields["candidates"]) assert.Equal(t, a.ID, fields["selected_profile_id"]) } + +func TestWorkflowImportProfileCatalog(t *testing.T) { + repos, db := newMatcherTestRepos(t) + agentID := createMatcherTestAgent(t, repos) + + global := createMatcherTestProfile(t, repos, agentID, "Codex", "gpt-5", "full") + disabled := createMatcherTestProfile(t, repos, agentID, "Codex", "disabled", "full") + workspace := createMatcherTestProfile(t, repos, agentID, "Codex", "workspace", "full") + if _, err := repos.AgentSettings.UpdateAgentProfileEnabled(context.Background(), disabled.ID, false); err != nil { + t.Fatalf("disable profile: %v", err) + } + if _, err := db.Exec(`UPDATE agent_profiles SET workspace_id = ? WHERE id = ?`, "workspace-1", workspace.ID); err != nil { + t.Fatalf("scope profile: %v", err) + } + + catalog := newWorkflowImportProfileCatalog(repos) + profiles, err := catalog.ListEligibleProfiles(context.Background()) + require.NoError(t, err) + require.Len(t, profiles, 1) + assert.Equal(t, global.ID, profiles[0].ID) + assert.Equal(t, global.Name, profiles[0].Name) + assert.Equal(t, global.UpdatedAt, profiles[0].UpdatedAt) + + selected, err := catalog.GetEligibleProfile(context.Background(), global.ID) + require.NoError(t, err) + require.NotNil(t, selected) + assert.Equal(t, global.ID, selected.ID) + + for _, id := range []string{disabled.ID, workspace.ID, "removed"} { + _, err := catalog.GetEligibleProfile(context.Background(), id) + if !errors.Is(err, workflowservice.ErrImportProfileNotFound) { + t.Fatalf("GetEligibleProfile(%q) error = %v, want ErrImportProfileNotFound", id, err) + } + } +} diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 6a89cd272a..1e85c66837 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -261,6 +261,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories buildAgentProfileResolver(repos), buildAgentProfileMatcher(repos, log), ) + workflowSvc.SetImportProfileCatalog(newWorkflowImportProfileCatalog(repos)) githubSvc, _, githubErr := initGitHubServiceRequired(cfg, dbPool, eventBus, repos.Secrets, log) if recordErr := recordRequiredStore(storeTracker, "github", githubErr); recordErr != nil { @@ -1874,6 +1875,11 @@ func (a *workflowProviderAdapter) UpdateWorkflow(ctx context.Context, workflow * return err } +// DeleteWorkflow implements the compensating cleanup used by workflow imports. +func (a *workflowProviderAdapter) DeleteWorkflow(ctx context.Context, id string) error { + return a.svc.DeleteWorkflow(ctx, id) +} + // buildAgentProfileResolver creates a resolver that converts profile IDs to portable form for export. func buildAgentProfileResolver(repos *Repositories) wfmodels.AgentProfileResolver { return func(profileID string) *wfmodels.AgentProfilePortable { diff --git a/apps/backend/internal/backendapp/workflow_import_profile_catalog.go b/apps/backend/internal/backendapp/workflow_import_profile_catalog.go new file mode 100644 index 0000000000..92bdc02cdd --- /dev/null +++ b/apps/backend/internal/backendapp/workflow_import_profile_catalog.go @@ -0,0 +1,78 @@ +package backendapp + +import ( + "context" + "database/sql" + "errors" + + agentsettingsmodels "github.com/kandev/kandev/internal/agent/settings/models" + settingsstore "github.com/kandev/kandev/internal/agent/settings/store" + workflowservice "github.com/kandev/kandev/internal/workflow/service" +) + +type workflowImportProfileCatalog struct { + repo settingsstore.Repository +} + +func newWorkflowImportProfileCatalog(repos *Repositories) workflowImportProfileCatalog { + return workflowImportProfileCatalog{repo: repos.AgentSettings} +} + +func (c workflowImportProfileCatalog) ListEligibleProfiles(ctx context.Context) ([]workflowservice.ImportProfileCandidate, error) { + if c.repo == nil { + return nil, workflowservice.ErrImportProfileCatalogUnavailable + } + agents, err := c.repo.ListAgents(ctx) + if err != nil { + return nil, err + } + profiles := make([]workflowservice.ImportProfileCandidate, 0) + // TODO: replace this per-agent loop with a batch profile query when the + // agent settings repository exposes one. + for _, agent := range agents { + candidates, listErr := c.repo.ListAgentProfiles(ctx, agent.ID) + if listErr != nil { + return nil, listErr + } + for _, profile := range candidates { + if !isEligibleWorkflowImportProfile(profile) { + continue + } + profiles = append(profiles, workflowImportProfileCandidate(profile)) + } + } + return profiles, nil +} + +func (c workflowImportProfileCatalog) GetEligibleProfile(ctx context.Context, id string) (*workflowservice.ImportProfileCandidate, error) { + if c.repo == nil { + return nil, workflowservice.ErrImportProfileCatalogUnavailable + } + profile, err := c.repo.GetAgentProfile(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, workflowservice.ErrImportProfileNotFound + } + return nil, err + } + if !isEligibleWorkflowImportProfile(profile) { + return nil, workflowservice.ErrImportProfileNotFound + } + candidate := workflowImportProfileCandidate(profile) + return &candidate, nil +} + +func isEligibleWorkflowImportProfile(profile *agentsettingsmodels.AgentProfile) bool { + return profile != nil && profile.ID != "" && profile.Enabled && profile.WorkspaceID == "" +} + +func workflowImportProfileCandidate(profile *agentsettingsmodels.AgentProfile) workflowservice.ImportProfileCandidate { + return workflowservice.ImportProfileCandidate{ + ID: profile.ID, + Name: profile.Name, + AgentName: profile.AgentDisplayName, + Model: profile.Model, + Mode: profile.Mode, + UpdatedAt: profile.UpdatedAt, + } +} diff --git a/apps/backend/internal/workflow/controller/controller.go b/apps/backend/internal/workflow/controller/controller.go index c6c0eedbc5..042dd76ddd 100644 --- a/apps/backend/internal/workflow/controller/controller.go +++ b/apps/backend/internal/workflow/controller/controller.go @@ -665,8 +665,9 @@ func (c *Controller) ListHistoryBySession(ctx context.Context, req ListHistoryRe // ImportWorkflowsRequest carries import data. type ImportWorkflowsRequest struct { - WorkspaceID string `json:"workspace_id"` - Data *models.WorkflowExport `json:"data"` + WorkspaceID string `json:"workspace_id"` + Data *models.WorkflowExport `json:"data"` + StepProfileBindings []service.ImportProfileBinding `json:"step_profile_bindings,omitempty"` } // ExportWorkflow exports a single workflow. @@ -684,5 +685,14 @@ func (c *Controller) ExportWorkflows(ctx context.Context, workspaceID string, wo // ImportWorkflows imports workflows into a workspace. func (c *Controller) ImportWorkflows(ctx context.Context, req ImportWorkflowsRequest) (*service.ImportResult, error) { + if req.StepProfileBindings != nil { + return c.svc.ImportWorkflowsWithBindings(ctx, req.WorkspaceID, req.Data, req.StepProfileBindings) + } return c.svc.ImportWorkflows(ctx, req.WorkspaceID, req.Data) } + +// PreviewImportWorkflows validates a portable document and returns the +// profile choices required before the browser can persist it. +func (c *Controller) PreviewImportWorkflows(ctx context.Context, workspaceID string, data *models.WorkflowExport) (*service.ImportProfilePreview, error) { + return c.svc.PreviewImportWorkflows(ctx, workspaceID, data) +} diff --git a/apps/backend/internal/workflow/handlers/handlers.go b/apps/backend/internal/workflow/handlers/handlers.go index 110bdec7a8..ef04301f71 100644 --- a/apps/backend/internal/workflow/handlers/handlers.go +++ b/apps/backend/internal/workflow/handlers/handlers.go @@ -2,8 +2,11 @@ package handlers import ( "context" + "encoding/json" "errors" + "fmt" "io" + "mime" "net/http" "strings" @@ -72,6 +75,7 @@ func (h *Handlers) registerHTTP(router *gin.Engine) { // Export/Import routes api.GET("/workflows/:id/export", h.httpExportWorkflow) api.GET("/workspaces/:id/workflows/export", h.httpExportWorkflows) + api.POST("/workspaces/:id/workflows/import/preview", h.httpPreviewImportWorkflows) api.POST("/workspaces/:id/workflows/import", h.httpImportWorkflows) // History routes @@ -369,33 +373,138 @@ func parseExportIDs(c *gin.Context) []string { } func (h *Handlers) httpImportWorkflows(c *gin.Context) { - const maxImportSize = 1 << 20 // 1 MB - body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxImportSize)) + body, tooLarge, err := readImportBody(c) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"}) return } + if tooLarge { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "Import request exceeds the 1 MiB limit"}) + return + } + + request, err := parseImportWorkflowsRequest(c, body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": capitalizeImportError(err.Error())}) + return + } + + resp, err := h.controller.ImportWorkflows(c.Request.Context(), request) + if err != nil { + h.writeImportError(c, err) + return + } + c.JSON(http.StatusOK, resp) +} + +func parseImportWorkflowsRequest(c *gin.Context, body []byte) (controller.ImportWorkflowsRequest, error) { + request := controller.ImportWorkflowsRequest{WorkspaceID: c.Param("id")} + if importMediaType(c) != "application/json" { + var data models.WorkflowExport + if err := yaml.Unmarshal(body, &data); err != nil { + return request, fmt.Errorf("invalid YAML: %w", err) + } + request.Data = &data + return request, nil + } + + var envelope struct { + YAML string `json:"yaml"` + StepProfileBindings []service.ImportProfileBinding `json:"step_profile_bindings"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return request, fmt.Errorf("invalid JSON: %w", err) + } + if strings.TrimSpace(envelope.YAML) == "" { + return request, errors.New("the import YAML is required") + } + var data models.WorkflowExport + if err := yaml.Unmarshal([]byte(envelope.YAML), &data); err != nil { + return request, fmt.Errorf("invalid YAML: %w", err) + } + if envelope.StepProfileBindings == nil { + envelope.StepProfileBindings = []service.ImportProfileBinding{} + } + request.Data = &data + request.StepProfileBindings = envelope.StepProfileBindings + return request, nil +} + +func capitalizeImportError(message string) string { + if message == "" { + return message + } + return strings.ToUpper(message[:1]) + message[1:] +} + +func (h *Handlers) httpPreviewImportWorkflows(c *gin.Context) { + body, tooLarge, err := readImportBody(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"}) + return + } + if tooLarge { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "Import request exceeds the 1 MiB limit"}) + return + } var data models.WorkflowExport if err := yaml.Unmarshal(body, &data); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid YAML: " + err.Error()}) return } - req := controller.ImportWorkflowsRequest{ - WorkspaceID: c.Param("id"), - Data: &data, - } - resp, err := h.controller.ImportWorkflows(c.Request.Context(), req) + resp, err := h.controller.PreviewImportWorkflows(c.Request.Context(), c.Param("id"), &data) if err != nil { - h.logger.Error("failed to import workflows", zap.Error(err)) - if writeNotVisible(c, err, "Workspace not found") { - return - } - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + h.writeImportError(c, err) return } c.JSON(http.StatusOK, resp) } +const maxImportSize = 1 << 20 + +func readImportBody(c *gin.Context) ([]byte, bool, error) { + body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxImportSize+1)) + if err != nil { + return nil, false, err + } + return body, len(body) > maxImportSize, nil +} + +func importMediaType(c *gin.Context) string { + mediaType, _, err := mime.ParseMediaType(c.GetHeader("Content-Type")) + if err != nil { + return strings.ToLower(strings.TrimSpace(strings.Split(c.GetHeader("Content-Type"), ";")[0])) + } + return strings.ToLower(mediaType) +} + +func (h *Handlers) writeImportError(c *gin.Context, err error) { + if writeNotVisible(c, err, "Workspace not found") { + return + } + var resolutionErr *service.ImportProfileResolutionError + if errors.As(err, &resolutionErr) { + c.JSON(http.StatusConflict, gin.H{ + "code": "workflow_import_profiles_required", + "error": resolutionErr.Error(), + "steps": resolutionErr.Conflicts, + }) + return + } + var bindingsErr *service.InvalidImportProfileBindingsError + if errors.As(err, &bindingsErr) { + c.JSON(http.StatusBadRequest, gin.H{"code": "invalid_workflow_import_profile_bindings", "error": bindingsErr.Error()}) + return + } + if errors.Is(err, service.ErrImportProfileCatalogUnavailable) { + h.logger.Error("failed to import workflows", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Import profile catalog is unavailable"}) + return + } + h.logger.Error("failed to import workflows", zap.Error(err)) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +} + // respondYAML marshals the value as YAML and writes it to the response. func (h *Handlers) respondYAML(c *gin.Context, v any) { data, err := yaml.Marshal(v) diff --git a/apps/backend/internal/workflow/handlers/http_handlers_test.go b/apps/backend/internal/workflow/handlers/http_handlers_test.go index 9b8e32c89e..8bd4f18d83 100644 --- a/apps/backend/internal/workflow/handlers/http_handlers_test.go +++ b/apps/backend/internal/workflow/handlers/http_handlers_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "gopkg.in/yaml.v3" @@ -16,6 +17,7 @@ import ( "github.com/kandev/kandev/internal/task/repository/repoerrors" "github.com/kandev/kandev/internal/workflow/controller" "github.com/kandev/kandev/internal/workflow/models" + workflowservice "github.com/kandev/kandev/internal/workflow/service" ) // fakeWorkflowProvider is the task-domain seam the workflow service reads @@ -88,6 +90,24 @@ type fakeWorkspaceProvider struct { workspaces map[string]*taskmodels.Workspace } +type fakeImportProfileCatalog struct { + profiles []workflowservice.ImportProfileCandidate +} + +func (c *fakeImportProfileCatalog) ListEligibleProfiles(context.Context) ([]workflowservice.ImportProfileCandidate, error) { + return append([]workflowservice.ImportProfileCandidate(nil), c.profiles...), nil +} + +func (c *fakeImportProfileCatalog) GetEligibleProfile(_ context.Context, id string) (*workflowservice.ImportProfileCandidate, error) { + for _, profile := range c.profiles { + if profile.ID == id { + copy := profile + return ©, nil + } + } + return nil, workflowservice.ErrImportProfileNotFound +} + func (p *fakeWorkspaceProvider) GetWorkspace(_ context.Context, id string) (*taskmodels.Workspace, error) { if ws, ok := p.workspaces[id]; ok { return ws, nil @@ -648,3 +668,216 @@ workflows: } }) } + +func TestPreviewImportWorkflowsEndpoint(t *testing.T) { + h := setupStepRouter(t) + h.service.SetWorkflowProvider(&fakeWorkflowProvider{}) + + rec := doRaw(t, h, http.MethodPost, "/api/v1/workspaces/ws-1/workflows/import/preview", `version: 1 +type: kandev_workflow +workflows: + - name: Previewed + steps: + - name: Start + position: 0 + color: bg-blue-500 +`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + + var got struct { + Skipped []string `json:"skipped"` + Steps []struct { + WorkflowIndex int `json:"workflow_index"` + StepPosition int `json:"step_position"` + WorkflowName string `json:"workflow_name"` + StepName string `json:"step_name"` + } `json:"steps"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Skipped) != 0 || len(got.Steps) != 0 { + t.Fatalf("preview = %#v, want no skipped workflows or profile steps", got) + } +} + +func TestImportWorkflowProfileSelectionHTTP(t *testing.T) { + updatedAt := time.Date(2026, 9, 17, 13, 0, 0, 0, time.UTC) + profile := workflowservice.ImportProfileCandidate{ + ID: "profile-1", Name: "Codex Default", AgentName: "Codex", Model: "gpt-5", Mode: "full", UpdatedAt: updatedAt, + } + + t.Run("preview includes an exact match", func(t *testing.T) { + h := setupStepRouter(t) + h.service.SetWorkflowProvider(&fakeWorkflowProvider{}) + h.service.SetImportProfileCatalog(&fakeImportProfileCatalog{profiles: []workflowservice.ImportProfileCandidate{profile}}) + h.service.SetAgentProfileFuncs(nil, func(string, string, string, string) string { return profile.ID }) + + rec := doRaw(t, h, http.MethodPost, "/api/v1/workspaces/ws-1/workflows/import/preview", `version: 1 +type: kandev_workflow +workflows: + - name: Previewed + steps: + - name: Build + position: 0 + color: bg-blue-500 + agent_profile: + agent_name: Codex + model: gpt-5 + mode: full +`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var got struct { + Steps []struct { + MatchedProfile *struct { + ID string `json:"id"` + } `json:"matched_profile"` + } `json:"steps"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Steps) != 1 || got.Steps[0].MatchedProfile == nil || got.Steps[0].MatchedProfile.ID != profile.ID { + t.Fatalf("preview = %#v", got) + } + }) + + t.Run("preview serializes an empty eligible catalog as an array", func(t *testing.T) { + h := setupStepRouter(t) + h.service.SetWorkflowProvider(&fakeWorkflowProvider{}) + h.service.SetImportProfileCatalog(&fakeImportProfileCatalog{}) + h.service.SetAgentProfileFuncs(nil, func(string, string, string, string) string { return "" }) + + rec := doRaw(t, h, http.MethodPost, "/api/v1/workspaces/ws-1/workflows/import/preview", `version: 1 +type: kandev_workflow +workflows: + - name: Missing profile + steps: + - name: Build + position: 0 + color: bg-blue-500 + agent_profile: + agent_name: Missing + model: unavailable + mode: full +`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var got struct { + Profiles json.RawMessage `json:"profiles"` + Steps []struct { + MatchedProfile *struct{} `json:"matched_profile"` + } `json:"steps"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if string(got.Profiles) != "[]" { + t.Fatalf("profiles = %s, want []", got.Profiles) + } + if len(got.Steps) != 1 || got.Steps[0].MatchedProfile != nil { + t.Fatalf("steps = %#v, want one unmatched step", got.Steps) + } + }) + + t.Run("stale selection is a structured conflict", func(t *testing.T) { + h := setupStepRouter(t) + provider := &fakeWorkflowProvider{} + h.service.SetWorkflowProvider(provider) + h.service.SetImportProfileCatalog(&fakeImportProfileCatalog{profiles: []workflowservice.ImportProfileCandidate{profile}}) + body := map[string]any{ + "yaml": `version: 1 +type: kandev_workflow +workflows: + - name: Selected + steps: + - name: Build + position: 0 + color: bg-blue-500 + agent_profile: + agent_name: Missing +`, + "step_profile_bindings": []map[string]any{{ + "workflow_index": 0, + "step_position": 0, + "requested_profile": map[string]any{ + "agent_name": "Missing", + }, + "profile_id": profile.ID, + "profile_updated_at": updatedAt.Add(-time.Minute).Format(time.RFC3339), + }}, + } + rec := doJSON(t, h.router, http.MethodPost, "/api/v1/workspaces/ws-1/workflows/import", body) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var got struct { + Code string `json:"code"` + Steps []struct { + Reason string `json:"reason"` + } `json:"steps"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Code != "workflow_import_profiles_required" || len(got.Steps) != 1 || got.Steps[0].Reason != workflowservice.ImportProfileReasonChanged { + t.Fatalf("conflict = %#v", got) + } + if len(provider.created) != 0 { + t.Fatalf("conflict created workflows: %v", provider.created) + } + }) + + t.Run("valid JSON envelope persists the selected ID", func(t *testing.T) { + h := setupStepRouter(t) + provider := &fakeWorkflowProvider{} + h.service.SetWorkflowProvider(provider) + h.service.SetImportProfileCatalog(&fakeImportProfileCatalog{profiles: []workflowservice.ImportProfileCandidate{profile}}) + body := map[string]any{ + "yaml": `version: 1 +type: kandev_workflow +workflows: + - name: Selected + steps: + - name: Build + position: 0 + color: bg-blue-500 + agent_profile: + agent_name: Missing +`, + "step_profile_bindings": []map[string]any{{ + "workflow_index": 0, + "step_position": 0, + "requested_profile": map[string]any{ + "agent_name": "Missing", + }, + "profile_id": profile.ID, + "profile_updated_at": updatedAt.Format(time.RFC3339), + }}, + } + rec := doJSON(t, h.router, http.MethodPost, "/api/v1/workspaces/ws-1/workflows/import", body) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + steps, err := h.repo.ListStepsByWorkflow(context.Background(), "created-Selected") + if err != nil { + t.Fatalf("list imported steps: %v", err) + } + if len(steps) != 1 || steps[0].AgentProfileID != profile.ID { + t.Fatalf("steps = %#v", steps) + } + }) + + t.Run("oversized body is rejected explicitly", func(t *testing.T) { + h := setupStepRouter(t) + rec := doRaw(t, h, http.MethodPost, "/api/v1/workspaces/ws-1/workflows/import/preview", strings.Repeat("x", maxImportSize+1)) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + }) +} diff --git a/apps/backend/internal/workflow/handlers/scope_harness_test.go b/apps/backend/internal/workflow/handlers/scope_harness_test.go index dd28c74fcb..8ab0b90ffb 100644 --- a/apps/backend/internal/workflow/handlers/scope_harness_test.go +++ b/apps/backend/internal/workflow/handlers/scope_harness_test.go @@ -316,6 +316,15 @@ func doRawAs(t *testing.T, h *scopedHarness, ctx context.Context, method, path, return rec } +func doRawYAMLAs(t *testing.T, h *scopedHarness, ctx context.Context, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)).WithContext(ctx) + req.Header.Set("Content-Type", "application/x-yaml") + rec := httptest.NewRecorder() + h.router.ServeHTTP(rec, req) + return rec +} + // dispatchAs routes one WS message through the registered handlers under ctx's // identity, the way the gateway dispatches an authenticated client's action. func dispatchAs(t *testing.T, h *scopedHarness, ctx context.Context, action string, payload any) *ws.Message { diff --git a/apps/backend/internal/workflow/handlers/step_reference_scope_test.go b/apps/backend/internal/workflow/handlers/step_reference_scope_test.go index 108690ea72..cca2097dfc 100644 --- a/apps/backend/internal/workflow/handlers/step_reference_scope_test.go +++ b/apps/backend/internal/workflow/handlers/step_reference_scope_test.go @@ -318,7 +318,7 @@ workflows: t.Run("another user's task is refused and nothing is written", func(t *testing.T) { h := setupScopedRouter(t) h.queries.reset() - rec := doRawAs(t, h, asUser(userB), http.MethodPost, "/api/v1/workspaces/ws-b/workflows/import", + rec := doRawYAMLAs(t, h, asUser(userB), http.MethodPost, "/api/v1/workspaces/ws-b/workflows/import", fmt.Sprintf(queueRunYAML, "task-a")) if rec.Code == http.StatusOK { t.Fatalf("import accepted a foreign task reference: %s", rec.Body.String()) @@ -331,7 +331,7 @@ workflows: t.Run("the caller's own task still imports", func(t *testing.T) { h := setupScopedRouter(t) - rec := doRawAs(t, h, asUser(userB), http.MethodPost, "/api/v1/workspaces/ws-b/workflows/import", + rec := doRawYAMLAs(t, h, asUser(userB), http.MethodPost, "/api/v1/workspaces/ws-b/workflows/import", fmt.Sprintf(queueRunYAML, "task-b")) requireStatus(t, rec, http.StatusOK) steps, err := h.repo.ListStepsByWorkflow(context.Background(), "created-Smuggled") @@ -345,7 +345,7 @@ workflows: t.Run("position-based transition targets still import", func(t *testing.T) { h := setupScopedRouter(t) - rec := doRawAs(t, h, asUser(userB), http.MethodPost, "/api/v1/workspaces/ws-b/workflows/import", positionalYAML) + rec := doRawYAMLAs(t, h, asUser(userB), http.MethodPost, "/api/v1/workspaces/ws-b/workflows/import", positionalYAML) requireStatus(t, rec, http.StatusOK) steps, err := h.repo.ListStepsByWorkflow(context.Background(), "created-Positional") if err != nil { diff --git a/apps/backend/internal/workflow/handlers/workflow_scope_test.go b/apps/backend/internal/workflow/handlers/workflow_scope_test.go index 2d82ac9d4d..7ed97b2270 100644 --- a/apps/backend/internal/workflow/handlers/workflow_scope_test.go +++ b/apps/backend/internal/workflow/handlers/workflow_scope_test.go @@ -78,7 +78,7 @@ func TestWorkflowKeyedRoutesDenyForeignOwner(t *testing.T) { h := setupScopedRouter(t) h.queries.reset() - rec := doRawAs(t, h, asUser(userB), http.MethodPost, "/api/v1/workspaces/ws-a/workflows/import", importYAML) + rec := doRawYAMLAs(t, h, asUser(userB), http.MethodPost, "/api/v1/workspaces/ws-a/workflows/import", importYAML) requireNotFound(t, rec) requireStepTableUntouched(t, h) if len(h.provider.created) != 0 { @@ -247,7 +247,7 @@ func TestWorkflowScopeOwnerKeepsFullAccess(t *testing.T) { map[string]any{"template_id": "template-1"}), http.StatusCreated) requireStatus(t, doAs(t, h, ctx, http.MethodDelete, "/api/v1/workflow/steps/"+newStepID, nil), http.StatusOK) - requireStatus(t, doRawAs(t, h, ctx, http.MethodPost, + requireStatus(t, doRawYAMLAs(t, h, ctx, http.MethodPost, "/api/v1/workspaces/ws-a/workflows/import", importYAML), http.StatusOK) if len(h.provider.created) != 1 || h.provider.created[0] != "Smuggled" { t.Fatalf("owner import created %v, want the imported workflow", h.provider.created) @@ -294,7 +294,7 @@ func TestWorkflowScopeNoOpsWithAuthDisabled(t *testing.T) { requireStatus(t, doAs(t, h, tc.ctx, http.MethodPut, "/api/v1/workflow/steps/"+h.stepB, map[string]any{"name": "Renamed"}), http.StatusOK) requireStatus(t, doAs(t, h, tc.ctx, http.MethodDelete, "/api/v1/workflow/steps/"+h.stepB, nil), http.StatusOK) - requireStatus(t, doRawAs(t, h, tc.ctx, http.MethodPost, + requireStatus(t, doRawYAMLAs(t, h, tc.ctx, http.MethodPost, "/api/v1/workspaces/ws-b/workflows/import", importYAML), http.StatusOK) var wsListed controller.ListStepsResponse diff --git a/apps/backend/internal/workflow/service/import_profile_selection.go b/apps/backend/internal/workflow/service/import_profile_selection.go new file mode 100644 index 0000000000..661a6e5dec --- /dev/null +++ b/apps/backend/internal/workflow/service/import_profile_selection.go @@ -0,0 +1,581 @@ +package service + +import ( + "context" + "errors" + "fmt" + "sort" + "time" + + "github.com/google/uuid" + taskmodels "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/workflow/models" + "go.uber.org/zap" +) + +const ( + ImportProfileReasonMissingSelection = "missing_selection" + ImportProfileReasonUnavailable = "unavailable_profile" + ImportProfileReasonChanged = "changed_profile" +) + +// ImportProfileCatalog supplies the profiles that the browser import flow may +// use. The agent settings domain owns eligibility; workflow import only reads +// this narrow catalog and never reimplements profile health or discovery. +type ImportProfileCatalog interface { + ListEligibleProfiles(ctx context.Context) ([]ImportProfileCandidate, error) + GetEligibleProfile(ctx context.Context, id string) (*ImportProfileCandidate, error) +} + +type workflowImportDeletionProvider interface { + DeleteWorkflow(ctx context.Context, id string) error +} + +// ImportProfileCandidate is an enabled, global agent profile that can be used +// as a replacement for a portable profile descriptor. +type ImportProfileCandidate struct { + ID string `json:"id"` + Name string `json:"name"` + AgentName string `json:"agent_name"` + Model string `json:"model"` + Mode string `json:"mode"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ImportProfileMatch struct { + ID string `json:"id"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ImportProfileStep struct { + WorkflowIndex int `json:"workflow_index"` + WorkflowName string `json:"workflow_name"` + StepPosition int `json:"step_position"` + StepName string `json:"step_name"` + RequestedProfile models.AgentProfilePortable `json:"requested_profile"` + MatchedProfile *ImportProfileMatch `json:"matched_profile,omitempty"` +} + +type ImportProfilePreview struct { + Skipped []string `json:"skipped"` + Profiles []ImportProfileCandidate `json:"profiles"` + Steps []ImportProfileStep `json:"steps"` +} + +type ImportProfileBinding struct { + WorkflowIndex int `json:"workflow_index"` + StepPosition int `json:"step_position"` + RequestedProfile *models.AgentProfilePortable `json:"requested_profile"` + ProfileID string `json:"profile_id"` + ProfileUpdatedAt time.Time `json:"profile_updated_at"` +} + +type ImportProfileConflict struct { + WorkflowIndex int `json:"workflow_index"` + StepPosition int `json:"step_position"` + WorkflowName string `json:"workflow_name"` + StepName string `json:"step_name"` + Reason string `json:"reason"` +} + +// ImportProfileResolutionError means the document is valid but the user must +// review one or more direct step profile bindings before persistence. +type ImportProfileResolutionError struct { + Conflicts []ImportProfileConflict +} + +func (e *ImportProfileResolutionError) Error() string { + return "workflow import requires agent profile selections" +} + +// InvalidImportProfileBindingsError means the browser submitted a binding +// envelope that does not describe the supplied portable document. +type InvalidImportProfileBindingsError struct { + Reason string +} + +func (e *InvalidImportProfileBindingsError) Error() string { + if e.Reason == "" { + return "invalid workflow import profile bindings" + } + return "invalid workflow import profile bindings: " + e.Reason +} + +// ErrImportProfileNotFound is returned by a catalog when a selected profile +// is no longer eligible or visible to the importing user. +var ( + ErrImportProfileNotFound = errors.New("import profile not found") + ErrImportProfileCatalogUnavailable = errors.New("import profile catalog is unavailable") +) + +func samePortableProfile(a, b *models.AgentProfilePortable) bool { + return a != nil && b != nil && a.AgentName == b.AgentName && a.Model == b.Model && a.Mode == b.Mode +} + +func importProfileKey(workflowIndex, stepPosition int) string { + return fmt.Sprintf("%d:%d", workflowIndex, stepPosition) +} + +func sortedImportProfileCandidates(profiles []ImportProfileCandidate) []ImportProfileCandidate { + sorted := append([]ImportProfileCandidate{}, profiles...) + sort.SliceStable(sorted, func(i, j int) bool { + left, right := sorted[i], sorted[j] + if left.Name != right.Name { + return left.Name < right.Name + } + if left.AgentName != right.AgentName { + return left.AgentName < right.AgentName + } + if left.Model != right.Model { + return left.Model < right.Model + } + if left.Mode != right.Mode { + return left.Mode < right.Mode + } + return left.ID < right.ID + }) + return sorted +} + +func validateImportExport(export *models.WorkflowExport) error { + if export == nil { + return fmt.Errorf("invalid export data: export is required") + } + if err := export.NormalizeCompletionPolicy(); err != nil { + return fmt.Errorf("invalid export data: %w", err) + } + if err := export.Validate(); err != nil { + return fmt.Errorf("invalid export data: %w", err) + } + return nil +} + +func collectImportProfileSteps( + export *models.WorkflowExport, + existingNames map[string]bool, +) ([]string, []ImportProfileStep) { + skipped := make([]string, 0) + steps := make([]ImportProfileStep, 0) + for workflowIndex, workflow := range export.Workflows { + if existingNames[workflow.Name] { + skipped = append(skipped, workflow.Name) + continue + } + for _, step := range workflow.Steps { + if step.AgentProfile == nil { + continue + } + steps = append(steps, ImportProfileStep{ + WorkflowIndex: workflowIndex, + WorkflowName: workflow.Name, + StepPosition: step.Position, + StepName: step.Name, + RequestedProfile: *step.AgentProfile, + }) + } + } + return skipped, steps +} + +func (s *Service) importProfileCandidates(ctx context.Context) ([]ImportProfileCandidate, error) { + if s.importProfileCatalog == nil { + return nil, ErrImportProfileCatalogUnavailable + } + profiles, err := s.importProfileCatalog.ListEligibleProfiles(ctx) + if err != nil { + return nil, fmt.Errorf("%w: list import profile candidates: %w", ErrImportProfileCatalogUnavailable, err) + } + return sortedImportProfileCandidates(profiles), nil +} + +func (s *Service) matchImportProfileSteps(preview *ImportProfilePreview) { + if s.matchProfile == nil { + return + } + byID := make(map[string]ImportProfileCandidate, len(preview.Profiles)) + for _, profile := range preview.Profiles { + byID[profile.ID] = profile + } + for index := range preview.Steps { + requested := preview.Steps[index].RequestedProfile + id := s.matchProfile(requested.AgentName, requested.Model, requested.Mode, "") + profile, ok := byID[id] + if !ok { + continue + } + preview.Steps[index].MatchedProfile = &ImportProfileMatch{ + ID: profile.ID, + UpdatedAt: profile.UpdatedAt, + } + } +} + +func (s *Service) SetImportProfileCatalog(catalog ImportProfileCatalog) { + s.importProfileCatalog = catalog +} + +func (s *Service) importExistingWorkflowNames(ctx context.Context, workspaceID string) (map[string]bool, error) { + existing, err := s.workflowProvider.ListWorkflows(ctx, workspaceID, true) + if err != nil { + return nil, fmt.Errorf("failed to list existing workflows: %w", err) + } + names := make(map[string]bool, len(existing)) + for _, workflow := range existing { + names[workflow.Name] = true + } + return names, nil +} + +type preparedImportedWorkflow struct { + portable models.WorkflowPortable + steps []*models.WorkflowStep + workflowProfileID string +} + +func (s *Service) prepareImportedWorkflow( + ctx context.Context, + pw models.WorkflowPortable, + directProfileIDs map[int]string, +) (preparedImportedWorkflow, error) { + posToID := make(map[int]string, len(pw.Steps)) + for _, sp := range pw.Steps { + posToID[sp.Position] = uuid.New().String() + } + + steps := make([]*models.WorkflowStep, 0, len(pw.Steps)) + for _, sp := range pw.Steps { + step := s.stepFromPortableWithMatcherOptions( + "pending-workflow", sp, posToID, s.matchProfile, "", false, + ) + if profileID := directProfileIDs[sp.Position]; profileID != "" { + step.AgentProfileID = profileID + } + if err := models.ValidateWorkflowStep(step); err != nil { + return preparedImportedWorkflow{}, fmt.Errorf("validate step %q: %w", sp.Name, err) + } + if err := s.validateImportedStepReferences(ctx, step, sp.Name); err != nil { + return preparedImportedWorkflow{}, err + } + steps = append(steps, step) + } + if err := validateWorkflowSessionTargets(steps); err != nil { + return preparedImportedWorkflow{}, fmt.Errorf("validate workflow session targets: %w", err) + } + + workflowProfileID := "" + if pw.AgentProfile != nil && s.matchProfile != nil { + workflowProfileID = s.matchProfile( + pw.AgentProfile.AgentName, + pw.AgentProfile.Model, + pw.AgentProfile.Mode, + "", + ) + } + return preparedImportedWorkflow{ + portable: pw, + steps: steps, + workflowProfileID: workflowProfileID, + }, nil +} + +func (s *Service) persistImportedWorkflow( + ctx context.Context, + workspaceID string, + prepared preparedImportedWorkflow, +) (*taskmodels.Workflow, error) { + pw := prepared.portable + wf, err := s.workflowProvider.CreateWorkflow(ctx, workspaceID, pw.Name, pw.Description) + if err != nil { + return nil, fmt.Errorf("create workflow: %w", err) + } + createdSteps := make([]*models.WorkflowStep, 0, len(prepared.steps)) + rollback := func(cause error) (*taskmodels.Workflow, error) { + if cleanupErr := s.rollbackImportedWorkflow(ctx, wf, createdSteps); cleanupErr != nil { + return nil, errors.Join(cause, fmt.Errorf("cleanup imported workflow: %w", cleanupErr)) + } + return nil, cause + } + + needsUpdate := false + if prepared.workflowProfileID != "" { + wf.AgentProfileID = prepared.workflowProfileID + needsUpdate = true + } + if pw.Prompt != "" { + wf.Prompt = pw.Prompt + needsUpdate = true + } + if needsUpdate { + if err := s.workflowProvider.UpdateWorkflow(ctx, wf); err != nil { + return rollback(fmt.Errorf("set workflow fields: %w", err)) + } + } + for _, step := range prepared.steps { + step.WorkflowID = wf.ID + if err := s.repo.CreateStep(ctx, step); err != nil { + return rollback(fmt.Errorf("create step %q: %w", step.Name, err)) + } + createdSteps = append(createdSteps, step) + } + return wf, nil +} + +func (s *Service) rollbackImportedWorkflow( + ctx context.Context, + wf *taskmodels.Workflow, + createdSteps []*models.WorkflowStep, +) error { + var cleanupErr error + for index := len(createdSteps) - 1; index >= 0; index-- { + if err := s.repo.DeleteStep(ctx, createdSteps[index].ID); err != nil { + cleanupErr = errors.Join(cleanupErr, fmt.Errorf("delete step %q: %w", createdSteps[index].Name, err)) + } + } + deleter, ok := s.workflowProvider.(workflowImportDeletionProvider) + if !ok { + return errors.Join(cleanupErr, errors.New("workflow provider cannot delete imported workflows")) + } + if err := deleter.DeleteWorkflow(ctx, wf.ID); err != nil { + cleanupErr = errors.Join(cleanupErr, fmt.Errorf("delete workflow %q: %w", wf.Name, err)) + } + return cleanupErr +} + +func (s *Service) importProfilePreviewData( + ctx context.Context, + export *models.WorkflowExport, + workspaceID string, +) (*ImportProfilePreview, error) { + if err := validateImportExport(export); err != nil { + return nil, err + } + existingNames, err := s.importExistingWorkflowNames(ctx, workspaceID) + if err != nil { + return nil, err + } + + skipped, steps := collectImportProfileSteps(export, existingNames) + preview := &ImportProfilePreview{ + Skipped: skipped, + Profiles: make([]ImportProfileCandidate, 0), + Steps: steps, + } + if len(preview.Steps) == 0 { + return preview, nil + } + profiles, err := s.importProfileCandidates(ctx) + if err != nil { + return nil, err + } + preview.Profiles = profiles + s.matchImportProfileSteps(preview) + return preview, nil +} + +func (s *Service) PreviewImportWorkflows( + ctx context.Context, + workspaceID string, + export *models.WorkflowExport, +) (*ImportProfilePreview, error) { + if err := s.AuthorizeWorkspace(ctx, workspaceID); err != nil { + return nil, err + } + return s.importProfilePreviewData(ctx, export, workspaceID) +} + +func expectedImportProfileSteps(export *models.WorkflowExport) map[string]models.StepPortable { + expected := make(map[string]models.StepPortable) + for workflowIndex, workflow := range export.Workflows { + for _, step := range workflow.Steps { + if step.AgentProfile == nil { + continue + } + expected[importProfileKey(workflowIndex, step.Position)] = step + } + } + return expected +} + +func validateImportProfileBindings( + bindings []ImportProfileBinding, + expected map[string]models.StepPortable, +) (map[string]ImportProfileBinding, error) { + byKey := make(map[string]ImportProfileBinding, len(bindings)) + for _, binding := range bindings { + key := importProfileKey(binding.WorkflowIndex, binding.StepPosition) + expectedStep, ok := expected[key] + if !ok { + return nil, &InvalidImportProfileBindingsError{Reason: fmt.Sprintf("unknown step %s", key)} + } + if _, duplicate := byKey[key]; duplicate { + return nil, &InvalidImportProfileBindingsError{Reason: fmt.Sprintf("duplicate step %s", key)} + } + if binding.RequestedProfile == nil || !samePortableProfile(binding.RequestedProfile, expectedStep.AgentProfile) { + return nil, &InvalidImportProfileBindingsError{Reason: fmt.Sprintf("profile descriptor does not match step %s", key)} + } + byKey[key] = binding + } + return byKey, nil +} + +func importProfileConflict( + workflowIndex, stepPosition int, + workflowName, stepName, reason string, +) ImportProfileConflict { + return ImportProfileConflict{ + WorkflowIndex: workflowIndex, + StepPosition: stepPosition, + WorkflowName: workflowName, + StepName: stepName, + Reason: reason, + } +} + +func (s *Service) resolveSelectedImportProfile( + ctx context.Context, + binding ImportProfileBinding, + key string, +) (*ImportProfileCandidate, string, error) { + if binding.ProfileUpdatedAt.IsZero() { + return nil, "", &InvalidImportProfileBindingsError{Reason: fmt.Sprintf("missing profile revision for step %s", key)} + } + if s.importProfileCatalog == nil { + return nil, "", ErrImportProfileCatalogUnavailable + } + profile, err := s.importProfileCatalog.GetEligibleProfile(ctx, binding.ProfileID) + if err != nil || profile == nil { + if err != nil && !errors.Is(err, ErrImportProfileNotFound) { + return nil, "", fmt.Errorf("%w: load selected import profile: %w", ErrImportProfileCatalogUnavailable, err) + } + return nil, ImportProfileReasonUnavailable, nil + } + if !profile.UpdatedAt.Equal(binding.ProfileUpdatedAt) { + return nil, ImportProfileReasonChanged, nil + } + return profile, "", nil +} + +func (s *Service) resolveImportProfileBindings( + ctx context.Context, + export *models.WorkflowExport, + existingNames map[string]bool, + byKey map[string]ImportProfileBinding, +) (map[int]map[int]string, []ImportProfileConflict, error) { + conflicts := make([]ImportProfileConflict, 0) + directProfileIDs := make(map[int]map[int]string) + for workflowIndex, workflow := range export.Workflows { + if existingNames[workflow.Name] { + continue + } + for _, step := range workflow.Steps { + if step.AgentProfile == nil { + continue + } + key := importProfileKey(workflowIndex, step.Position) + binding, ok := byKey[key] + if !ok || binding.ProfileID == "" { + conflicts = append(conflicts, importProfileConflict( + workflowIndex, step.Position, workflow.Name, step.Name, ImportProfileReasonMissingSelection, + )) + continue + } + profile, reason, err := s.resolveSelectedImportProfile(ctx, binding, key) + if err != nil { + return nil, nil, err + } + if reason != "" { + conflicts = append(conflicts, importProfileConflict( + workflowIndex, step.Position, workflow.Name, step.Name, reason, + )) + continue + } + workflowProfiles := directProfileIDs[workflowIndex] + if workflowProfiles == nil { + workflowProfiles = make(map[int]string) + directProfileIDs[workflowIndex] = workflowProfiles + } + workflowProfiles[step.Position] = profile.ID + } + } + return directProfileIDs, conflicts, nil +} + +func (s *Service) prepareImportedWorkflows( + ctx context.Context, + export *models.WorkflowExport, + existingNames map[string]bool, + directProfileIDs map[int]map[int]string, +) ([]preparedImportedWorkflow, *ImportResult, error) { + prepared := make([]preparedImportedWorkflow, 0, len(export.Workflows)) + result := &ImportResult{} + for index, workflow := range export.Workflows { + if existingNames[workflow.Name] { + result.Skipped = append(result.Skipped, workflow.Name) + continue + } + item, err := s.prepareImportedWorkflow(ctx, workflow, directProfileIDs[index]) + if err != nil { + return nil, nil, fmt.Errorf("failed to import workflow %q: %w", workflow.Name, err) + } + prepared = append(prepared, item) + result.Created = append(result.Created, workflow.Name) + } + return prepared, result, nil +} + +func (s *Service) persistImportedWorkflows( + ctx context.Context, + workspaceID string, + prepared []preparedImportedWorkflow, +) error { + for _, item := range prepared { + if _, err := s.persistImportedWorkflow(ctx, workspaceID, item); err != nil { + return fmt.Errorf("failed to import workflow %q: %w", item.portable.Name, err) + } + } + return nil +} + +func (s *Service) ImportWorkflowsWithBindings( + ctx context.Context, + workspaceID string, + export *models.WorkflowExport, + bindings []ImportProfileBinding, +) (*ImportResult, error) { + if err := s.AuthorizeWorkspace(ctx, workspaceID); err != nil { + return nil, err + } + if err := validateImportExport(export); err != nil { + return nil, err + } + existingNames, err := s.importExistingWorkflowNames(ctx, workspaceID) + if err != nil { + return nil, err + } + + expected := expectedImportProfileSteps(export) + byKey, err := validateImportProfileBindings(bindings, expected) + if err != nil { + return nil, err + } + directProfileIDs, conflicts, err := s.resolveImportProfileBindings(ctx, export, existingNames, byKey) + if err != nil { + return nil, err + } + if len(conflicts) > 0 { + return nil, &ImportProfileResolutionError{Conflicts: conflicts} + } + + prepared, result, err := s.prepareImportedWorkflows(ctx, export, existingNames, directProfileIDs) + if err != nil { + return nil, err + } + if err := s.persistImportedWorkflows(ctx, workspaceID, prepared); err != nil { + return nil, err + } + 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 +} diff --git a/apps/backend/internal/workflow/service/import_profile_selection_test.go b/apps/backend/internal/workflow/service/import_profile_selection_test.go new file mode 100644 index 0000000000..8ffaca650c --- /dev/null +++ b/apps/backend/internal/workflow/service/import_profile_selection_test.go @@ -0,0 +1,256 @@ +package service + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/workflow/models" +) + +type importProfileCatalogFixture struct { + profiles []ImportProfileCandidate +} + +func (f *importProfileCatalogFixture) ListEligibleProfiles(context.Context) ([]ImportProfileCandidate, error) { + return append([]ImportProfileCandidate(nil), f.profiles...), nil +} + +func (f *importProfileCatalogFixture) GetEligibleProfile(_ context.Context, id string) (*ImportProfileCandidate, error) { + for _, profile := range f.profiles { + if profile.ID == id { + copy := profile + return ©, nil + } + } + return nil, ErrImportProfileNotFound +} + +func TestPreviewImportWorkflowsReportsMatchedAndMissingDirectProfiles(t *testing.T) { + svc, _, provider := setupTestServiceWithProvider(t) + provider.addWorkflow("existing", "ws-1", "Already there") + + matchedAt := time.Date(2026, 9, 17, 13, 0, 0, 0, time.UTC) + svc.SetImportProfileCatalog(&importProfileCatalogFixture{profiles: []ImportProfileCandidate{ + {ID: "matched", Name: "Codex Default", AgentName: "Codex", Model: "gpt-5", Mode: "full", UpdatedAt: matchedAt}, + {ID: "replacement", Name: "Claude Fallback", AgentName: "Claude", Model: "sonnet", Mode: "full", UpdatedAt: matchedAt}, + }}) + svc.SetAgentProfileFuncs(nil, func(agentName, model, mode, _ string) string { + if agentName == "Codex" && model == "gpt-5" && mode == "full" { + return "matched" + } + return "" + }) + + preview, err := svc.PreviewImportWorkflows(context.Background(), "ws-1", &models.WorkflowExport{ + Version: models.ExportVersion, + Type: models.ExportType, + Workflows: []models.WorkflowPortable{ + {Name: "Already there", Steps: []models.StepPortable{{Name: "Skipped", Position: 0}}}, + {Name: "Needs profiles", Steps: []models.StepPortable{ + {Name: "Build", Position: 0, AgentProfile: &models.AgentProfilePortable{AgentName: "Codex", Model: "gpt-5", Mode: "full"}}, + {Name: "Review", Position: 1, AgentProfile: &models.AgentProfilePortable{AgentName: "Unknown", Model: "model", Mode: "mode"}}, + }}, + }, + }) + + require.NoError(t, err) + assert.Equal(t, []string{"Already there"}, preview.Skipped) + require.Len(t, preview.Steps, 2) + assert.Equal(t, 1, preview.Steps[0].WorkflowIndex) + assert.Equal(t, "Build", preview.Steps[0].StepName) + require.NotNil(t, preview.Steps[0].MatchedProfile) + assert.Equal(t, "matched", preview.Steps[0].MatchedProfile.ID) + assert.Equal(t, matchedAt, preview.Steps[0].MatchedProfile.UpdatedAt) + assert.Nil(t, preview.Steps[1].MatchedProfile) + assert.Len(t, preview.Profiles, 2) + assert.Len(t, provider.workflows, 1, "preview must not create workflows") +} + +func TestImportWorkflowsWithBindingsUsesExactIndependentSelections(t *testing.T) { + svc, _, provider := setupTestServiceWithProvider(t) + updatedAt := time.Date(2026, 9, 17, 13, 0, 0, 0, time.UTC) + svc.SetImportProfileCatalog(&importProfileCatalogFixture{profiles: []ImportProfileCandidate{ + {ID: "profile-a", Name: "A", AgentName: "Agent A", Model: "model-a", Mode: "mode-a", UpdatedAt: updatedAt}, + {ID: "profile-b", Name: "B", AgentName: "Agent B", Model: "model-b", Mode: "mode-b", UpdatedAt: updatedAt}, + }}) + svc.SetAgentProfileFuncs(nil, func(string, string, string, string) string { + t.Fatal("interactive import rematched a directly selected profile") + return "" + }) + + requested := &models.AgentProfilePortable{AgentName: "Missing", Model: "missing", Mode: "mode"} + export := &models.WorkflowExport{ + Version: models.ExportVersion, + Type: models.ExportType, + Workflows: []models.WorkflowPortable{{ + Name: "Selected profiles", + Steps: []models.StepPortable{ + {Name: "First", Position: 0, AgentProfile: requested}, + {Name: "Second", Position: 1, AgentProfile: requested}, + }, + }}, + } + + result, err := svc.ImportWorkflowsWithBindings(context.Background(), "ws-1", export, []ImportProfileBinding{ + {WorkflowIndex: 0, StepPosition: 0, RequestedProfile: requested, ProfileID: "profile-a", ProfileUpdatedAt: updatedAt}, + {WorkflowIndex: 0, StepPosition: 1, RequestedProfile: requested, ProfileID: "profile-b", ProfileUpdatedAt: updatedAt}, + }) + + require.NoError(t, err) + assert.Equal(t, []string{"Selected profiles"}, result.Created) + steps, err := svc.repo.ListStepsByWorkflow(context.Background(), "imported-Selected profiles") + require.NoError(t, err) + require.Len(t, steps, 2) + assert.Equal(t, "profile-a", steps[0].AgentProfileID) + assert.Equal(t, "profile-b", steps[1].AgentProfileID) + assert.Len(t, provider.workflows, 1) +} + +func TestImportWorkflowsWithBindingsRejectsRevisionChangeBeforeAnyWrite(t *testing.T) { + svc, _, provider := setupTestServiceWithProvider(t) + svc.SetImportProfileCatalog(&importProfileCatalogFixture{profiles: []ImportProfileCandidate{ + {ID: "profile-a", Name: "A", AgentName: "Agent A", Model: "model-a", Mode: "mode-a", UpdatedAt: time.Date(2026, 9, 17, 14, 0, 0, 0, time.UTC)}, + }}) + requested := &models.AgentProfilePortable{AgentName: "Missing", Model: "missing", Mode: "mode"} + export := &models.WorkflowExport{ + Version: models.ExportVersion, + Type: models.ExportType, + Workflows: []models.WorkflowPortable{{ + Name: "Changed profile", + Steps: []models.StepPortable{{Name: "Build", Position: 0, AgentProfile: requested}}, + }}, + } + + _, err := svc.ImportWorkflowsWithBindings(context.Background(), "ws-1", export, []ImportProfileBinding{ + {WorkflowIndex: 0, StepPosition: 0, RequestedProfile: requested, ProfileID: "profile-a", ProfileUpdatedAt: time.Date(2026, 9, 17, 13, 0, 0, 0, time.UTC)}, + }) + + var conflict *ImportProfileResolutionError + require.ErrorAs(t, err, &conflict) + require.Len(t, conflict.Conflicts, 1) + assert.Equal(t, ImportProfileReasonChanged, conflict.Conflicts[0].Reason) + assert.Empty(t, provider.workflows, "revision conflicts must not create any workflow") +} + +func TestImportWorkflowsWithBindingsRejectsMissingBindingWithoutWrites(t *testing.T) { + svc, _, provider := setupTestServiceWithProvider(t) + svc.SetImportProfileCatalog(&importProfileCatalogFixture{}) + export := &models.WorkflowExport{ + Version: models.ExportVersion, + Type: models.ExportType, + Workflows: []models.WorkflowPortable{{ + Name: "Missing selection", + Steps: []models.StepPortable{{Name: "Build", Position: 0, AgentProfile: &models.AgentProfilePortable{AgentName: "Missing"}}}, + }}, + } + + _, err := svc.ImportWorkflowsWithBindings(context.Background(), "ws-1", export, nil) + var conflict *ImportProfileResolutionError + require.ErrorAs(t, err, &conflict) + require.Len(t, conflict.Conflicts, 1) + assert.Equal(t, ImportProfileReasonMissingSelection, conflict.Conflicts[0].Reason) + assert.Empty(t, provider.workflows) +} + +func TestImportWorkflowsWithBindingsCleansUpWorkflowWhenPersistenceFails(t *testing.T) { + svc, _, provider := setupTestServiceWithProvider(t) + provider.forceUpdateWorkflowErr = fmt.Errorf("update workflow failed") + export := &models.WorkflowExport{ + Version: models.ExportVersion, + Type: models.ExportType, + Workflows: []models.WorkflowPortable{{ + Name: "Failed persistence", + Prompt: "Imported prompt", + Steps: []models.StepPortable{{Name: "Build", Position: 0}}, + }}, + } + + _, err := svc.ImportWorkflowsWithBindings(context.Background(), "ws-1", export, nil) + require.Error(t, err) + assert.Empty(t, provider.workflows, "a failed import must not leave the workflow available for retries") + assert.Equal(t, []string{"imported-Failed persistence"}, provider.deleted) +} + +func TestImportWorkflowsWithBindingsCleansUpPreviouslyCreatedSteps(t *testing.T) { + svc, db, provider := setupTestServiceWithProvider(t) + _, err := db.Exec(` + CREATE TRIGGER fail_import_step + BEFORE INSERT ON workflow_steps + WHEN NEW.name = 'Fail' + BEGIN + SELECT RAISE(ABORT, 'forced step failure'); + END`) + require.NoError(t, err) + export := &models.WorkflowExport{ + Version: models.ExportVersion, + Type: models.ExportType, + Workflows: []models.WorkflowPortable{{ + Name: "Failed step persistence", + Steps: []models.StepPortable{ + {Name: "Create", Position: 0}, + {Name: "Fail", Position: 1}, + }, + }}, + } + + _, err = svc.ImportWorkflowsWithBindings(context.Background(), "ws-1", export, nil) + require.Error(t, err) + assert.Empty(t, provider.workflows) + assert.Equal(t, []string{"imported-Failed step persistence"}, provider.deleted) + steps, err := svc.repo.ListStepsByWorkflow(context.Background(), "imported-Failed step persistence") + require.NoError(t, err) + assert.Empty(t, steps, "a failed import must remove steps created before the failing step") +} + +func TestValidateImportProfileBindingsRejectsMalformedBindingSets(t *testing.T) { + requested := &models.AgentProfilePortable{AgentName: "Agent", Model: "model", Mode: "mode"} + export := &models.WorkflowExport{ + Version: models.ExportVersion, + Type: models.ExportType, + Workflows: []models.WorkflowPortable{{ + Name: "Imported", + Steps: []models.StepPortable{{Name: "Build", Position: 0, AgentProfile: requested}}, + }}, + } + expected := expectedImportProfileSteps(export) + + tests := []struct { + name string + bindings []ImportProfileBinding + }{ + { + name: "unknown step key", + bindings: []ImportProfileBinding{{ + WorkflowIndex: 0, StepPosition: 99, RequestedProfile: requested, ProfileID: "profile", + }}, + }, + { + name: "duplicate binding", + bindings: []ImportProfileBinding{ + {WorkflowIndex: 0, StepPosition: 0, RequestedProfile: requested, ProfileID: "profile"}, + {WorkflowIndex: 0, StepPosition: 0, RequestedProfile: requested, ProfileID: "profile"}, + }, + }, + { + name: "descriptor mismatch", + bindings: []ImportProfileBinding{{ + WorkflowIndex: 0, StepPosition: 0, + RequestedProfile: &models.AgentProfilePortable{AgentName: "Other"}, + ProfileID: "profile", + }}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := validateImportProfileBindings(test.bindings, expected) + var bindingsErr *InvalidImportProfileBindingsError + require.ErrorAs(t, err, &bindingsErr) + }) + } +} diff --git a/apps/backend/internal/workflow/service/service.go b/apps/backend/internal/workflow/service/service.go index e4ea395d3d..0431634e92 100644 --- a/apps/backend/internal/workflow/service/service.go +++ b/apps/backend/internal/workflow/service/service.go @@ -33,6 +33,7 @@ type Service struct { workspaceProvider WorkspaceProvider resolveProfile models.AgentProfileResolver matchProfile models.AgentProfileMatcher + importProfileCatalog ImportProfileCatalog syncOps SyncWorkflowOps sessionAccessChecker func(context.Context, string) error // workflowAccessChecker / workspaceAccessChecker carry the task domain's @@ -902,6 +903,20 @@ func (s *Service) stepFromPortable(workflowID string, sp models.StepPortable, po // profile IDs embedded in existing review actions while imports use the normal // matcher directly. func (s *Service) stepFromPortableWithMatcher(workflowID string, sp models.StepPortable, posToID map[int]string, matchProfile models.AgentProfileMatcher, existingProfileID string) *models.WorkflowStep { + return s.stepFromPortableWithMatcherOptions(workflowID, sp, posToID, matchProfile, existingProfileID, true) +} + +// stepFromPortableWithMatcherOptions keeps review-action profile conversion +// available while allowing the interactive importer to bind direct step +// profiles from its validated selection map without invoking the matcher. +func (s *Service) stepFromPortableWithMatcherOptions( + workflowID string, + sp models.StepPortable, + posToID map[int]string, + matchProfile models.AgentProfileMatcher, + existingProfileID string, + matchDirectProfile bool, +) *models.WorkflowStep { step := &models.WorkflowStep{ ID: posToID[sp.Position], WorkflowID: workflowID, @@ -923,7 +938,7 @@ func (s *Service) stepFromPortableWithMatcher(workflowID string, sp models.StepP WIPLimit: sp.WIPLimit, PullFromStepID: sp.PullFromStepID(posToID), } - if sp.AgentProfile != nil && matchProfile != nil { + if matchDirectProfile && sp.AgentProfile != nil && matchProfile != nil { step.AgentProfileID = matchProfile(sp.AgentProfile.AgentName, sp.AgentProfile.Model, sp.AgentProfile.Mode, existingProfileID) } return step diff --git a/apps/backend/internal/workflow/service/service_test.go b/apps/backend/internal/workflow/service/service_test.go index 7e2b88e003..9edf90bbaa 100644 --- a/apps/backend/internal/workflow/service/service_test.go +++ b/apps/backend/internal/workflow/service/service_test.go @@ -66,6 +66,7 @@ func insertWorkflow(t *testing.T, db *sqlx.DB, id, name string) { type mockWorkflowProvider struct { workflows []*taskmodels.Workflow getWorkflowCalls int + deleted []string // forceUpdateWorkflowErr, when set, makes UpdateWorkflow fail without // mutating state - used to test that a rebind Warn never fires for a // change that was not actually persisted. @@ -123,6 +124,17 @@ func (m *mockWorkflowProvider) UpdateWorkflow(_ context.Context, workflow *taskm return fmt.Errorf("workflow %s not found", workflow.ID) } +func (m *mockWorkflowProvider) DeleteWorkflow(_ context.Context, id string) error { + m.deleted = append(m.deleted, id) + for i, wf := range m.workflows { + if wf.ID == id { + m.workflows = append(m.workflows[:i], m.workflows[i+1:]...) + return nil + } + } + return fmt.Errorf("workflow %s not found", id) +} + func (m *mockWorkflowProvider) addWorkflow(id, workspaceID, name string) { now := time.Now().UTC() m.workflows = append(m.workflows, &taskmodels.Workflow{ diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index 3dbc2b85d8..db433e072d 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -30,6 +30,8 @@ import type { ListWorkflowTemplatesResponse, WorkflowTemplate, StepDefinition, + WorkflowImportPreview, + WorkflowImportProfileBinding, } from "@/lib/types/http"; const { apiBaseUrl } = getBackendConfig(); @@ -46,9 +48,11 @@ async function fetchJson(url: string, options?: RequestInit): Promise { const text = response.status === 204 ? "" : await response.text(); if (!response.ok) { let message = `Request failed: ${response.status} ${response.statusText}`; + let errorBody: unknown = null; if (text) { try { const body = JSON.parse(text) as { error?: string; message?: string }; + errorBody = body; const detail = body.error ?? body.message; if (detail) { message = detail; @@ -57,7 +61,10 @@ async function fetchJson(url: string, options?: RequestInit): Promise { // body was not JSON, fall back to status text } } - throw new Error(message); + const error = new Error(message) as Error & { status: number; body: unknown }; + error.status = response.status; + error.body = errorBody; + throw error; } if (!text) { return undefined as T; @@ -688,9 +695,23 @@ export async function exportAllWorkflowsAction( export async function importWorkflowsAction( workspaceId: string, yamlContent: string, + stepProfileBindings: WorkflowImportProfileBinding[] = [], ): Promise { return fetchJson( - `${apiBaseUrl}/api/v1/workspaces/${workspaceId}/workflows/import`, + `${apiBaseUrl}/api/v1/workspaces/${encodeURIComponent(workspaceId)}/workflows/import`, + { + method: "POST", + body: JSON.stringify({ yaml: yamlContent, step_profile_bindings: stepProfileBindings }), + }, + ); +} + +export async function previewWorkflowImportAction( + workspaceId: string, + yamlContent: string, +): Promise { + return fetchJson( + `${apiBaseUrl}/api/v1/workspaces/${encodeURIComponent(workspaceId)}/workflows/import/preview`, { method: "POST", headers: { "Content-Type": "application/x-yaml" }, diff --git a/apps/web/app/settings/workspace/use-workflow-import.test.ts b/apps/web/app/settings/workspace/use-workflow-import.test.ts new file mode 100644 index 0000000000..123490e207 --- /dev/null +++ b/apps/web/app/settings/workspace/use-workflow-import.test.ts @@ -0,0 +1,277 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { importWorkflowsAction, previewWorkflowImportAction } from "@/app/actions/workspaces"; +import type { WorkflowImportPreview, Workspace } from "@/lib/types/http"; +import { useWorkflowImport } from "./use-workflow-import"; + +vi.mock("@/app/actions/workspaces", () => ({ + importWorkflowsAction: vi.fn(), + previewWorkflowImportAction: vi.fn(), +})); + +const workspace = { id: "workspace-1", name: "Workspace" } as Workspace; +const profileA = { + id: "profile-a", + name: "Profile A", + agent_name: "Agent A", + model: "model-a", + mode: "mode-a", + updated_at: "2026-09-17T13:00:00Z", +}; +const profileB = { + id: "profile-b", + name: "Profile B", + agent_name: "Agent B", + model: "model-b", + mode: "mode-b", + updated_at: "2026-09-17T13:00:00Z", +}; + +const preview: WorkflowImportPreview = { + skipped: [], + profiles: [profileA, profileB], + steps: [ + { + workflow_index: 0, + workflow_name: "Imported", + step_position: 0, + step_name: "Build", + requested_profile: { agent_name: "Missing A", model: "a", mode: "mode" }, + matched_profile: { id: profileA.id, updated_at: profileA.updated_at }, + }, + { + workflow_index: 0, + workflow_name: "Imported", + step_position: 1, + step_name: "Review", + requested_profile: { agent_name: "Missing B", model: "b", mode: "mode" }, + }, + ], +}; +const importYaml = "portable yaml"; + +function renderImportHook() { + const routerMock = { refresh: vi.fn() }; + const toast = vi.fn(); + const view = renderHook(() => + useWorkflowImport({ workspace, router: routerMock as never, toast }), + ); + return { ...view, router: routerMock, toast }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(previewWorkflowImportAction).mockResolvedValue(preview); + vi.mocked(importWorkflowsAction).mockResolvedValue({ created: ["Imported"], skipped: [] }); +}); + +it("previews first, keeps exact matches, and submits independent selections", async () => { + const { result, router, toast } = renderImportHook(); + + act(() => result.current.setImportYaml(importYaml)); + await act(async () => { + await result.current.handleImport(); + }); + + expect(previewWorkflowImportAction).toHaveBeenCalledWith(workspace.id, importYaml); + expect(result.current.importPhase).toBe("resolving"); + expect(result.current.activeStepKey).toBeNull(); + expect(result.current.selections).toEqual({ "0:0": profileA.id }); + expect(importWorkflowsAction).not.toHaveBeenCalled(); + + act(() => result.current.selectProfile("0:1", profileB.id)); + await act(async () => { + await result.current.handleImport(); + }); + + expect(importWorkflowsAction).toHaveBeenCalledWith(workspace.id, importYaml, [ + { + workflow_index: 0, + step_position: 0, + requested_profile: preview.steps[0].requested_profile, + profile_id: profileA.id, + profile_updated_at: profileA.updated_at, + }, + { + workflow_index: 0, + step_position: 1, + requested_profile: preview.steps[1].requested_profile, + profile_id: profileB.id, + profile_updated_at: profileB.updated_at, + }, + ]); + expect(router.refresh).toHaveBeenCalledOnce(); + expect(toast).toHaveBeenCalled(); + expect(result.current.importYaml).toBe(""); +}); + +it("ignores a stale preview after the draft changes", async () => { + let resolvePreview!: (value: WorkflowImportPreview) => void; + vi.mocked(previewWorkflowImportAction).mockReturnValueOnce( + new Promise((resolve) => { + resolvePreview = resolve; + }), + ); + const { result } = renderImportHook(); + + act(() => result.current.setImportYaml("first")); + const request = result.current.handleImport(); + act(() => result.current.setImportYaml("second")); + resolvePreview(preview); + await act(async () => { + await request; + }); + + expect(result.current.preview).toBeNull(); + expect(result.current.importYaml).toBe("second"); + expect(importWorkflowsAction).not.toHaveBeenCalled(); +}); + +it("retains valid selections and refreshes the preview after a profile conflict", async () => { + const { result } = renderImportHook(); + const conflict = { + workflow_index: 0, + step_position: 0, + workflow_name: "Imported", + step_name: "Build", + reason: "changed_profile", + } as const; + const conflictError = Object.assign(new Error("profile changed"), { + body: { + code: "workflow_import_profiles_required", + error: "workflow import requires agent profile selections", + steps: [conflict], + }, + }); + vi.mocked(importWorkflowsAction).mockRejectedValueOnce(conflictError); + const refreshedPreview: WorkflowImportPreview = { + ...preview, + profiles: [{ ...profileA, updated_at: "2026-09-17T14:00:00Z" }, profileB], + }; + + act(() => result.current.setImportYaml(importYaml)); + await act(async () => { + await result.current.handleImport(); + }); + act(() => result.current.selectProfile("0:1", profileB.id)); + await act(async () => { + await result.current.handleImport(); + }); + + expect(result.current.importPhase).toBe("resolving"); + expect(result.current.profileConflicts).toEqual([conflict]); + expect(result.current.selections).toEqual({ "0:1": profileB.id }); + + vi.mocked(previewWorkflowImportAction).mockResolvedValueOnce(refreshedPreview); + await act(async () => { + await result.current.retryPreview(); + }); + + expect(result.current.selections).toEqual({ "0:1": profileB.id }); + expect(result.current.missingSteps).toEqual([refreshedPreview.steps[0]]); +}); + +it("does not submit a late all-matched preview after cancel and reopen", async () => { + let resolvePreview!: (value: WorkflowImportPreview) => void; + vi.mocked(previewWorkflowImportAction).mockReturnValueOnce( + new Promise((resolve) => { + resolvePreview = resolve; + }), + ); + const { result } = renderImportHook(); + const allMatchedPreview: WorkflowImportPreview = { + ...preview, + steps: preview.steps.map((step) => ({ + ...step, + matched_profile: { id: profileA.id, updated_at: profileA.updated_at }, + })), + }; + + act(() => result.current.setIsImportDialogOpen(true)); + act(() => result.current.setImportYaml(importYaml)); + let pendingPreview: Promise | undefined; + act(() => { + pendingPreview = result.current.handleImport(); + }); + await waitFor(() => expect(result.current.importPhase).toBe("previewing")); + + act(() => result.current.setIsImportDialogOpen(false)); + act(() => result.current.setIsImportDialogOpen(true)); + resolvePreview(allMatchedPreview); + await act(async () => { + await pendingPreview; + }); + + expect(importWorkflowsAction).not.toHaveBeenCalled(); + expect(result.current.importPhase).toBe("editing"); +}); + +it("ignores a late file-read result after the import dialog is canceled", () => { + const reader = { + onload: null, + readAsText: vi.fn(), + } as unknown as FileReader; + function MockFileReader() { + return reader; + } + const fileReader = vi + .spyOn(globalThis, "FileReader") + .mockImplementation(MockFileReader as unknown as typeof FileReader); + + try { + const { result } = renderImportHook(); + act(() => result.current.setIsImportDialogOpen(true)); + act(() => { + result.current.handleFileUpload({ + target: { files: [new File(["late yaml"], "late.yaml")], value: "" }, + } as never); + }); + + act(() => result.current.setIsImportDialogOpen(false)); + act(() => { + reader.onload?.call(reader, { + target: { result: "late yaml" }, + } as ProgressEvent); + }); + + expect(result.current.importYaml).toBe(""); + } finally { + fileReader.mockRestore(); + } +}); + +it("does not submit twice while the final request is pending", async () => { + const { result } = renderImportHook(); + const exactPreview: WorkflowImportPreview = { + ...preview, + steps: preview.steps.map((step) => ({ + ...step, + matched_profile: { id: profileA.id, updated_at: profileA.updated_at }, + })), + }; + vi.mocked(previewWorkflowImportAction).mockResolvedValueOnce(exactPreview); + act(() => result.current.setImportYaml(importYaml)); + + let resolveImport!: (value: { created: string[]; skipped: string[] }) => void; + vi.mocked(importWorkflowsAction).mockReturnValueOnce( + new Promise((resolve) => { + resolveImport = resolve; + }), + ); + let firstSubmit: Promise | undefined; + act(() => { + firstSubmit = result.current.handleImport(); + }); + await waitFor(() => expect(result.current.importPhase).toBe("submitting")); + act(() => result.current.setImportYaml("edited while submitting")); + await act(async () => { + await result.current.handleImport(); + }); + expect(previewWorkflowImportAction).toHaveBeenCalledOnce(); + expect(importWorkflowsAction).toHaveBeenCalledOnce(); + + resolveImport({ created: ["Imported"], skipped: [] }); + await act(async () => { + await firstSubmit; + }); +}); diff --git a/apps/web/app/settings/workspace/use-workflow-import.ts b/apps/web/app/settings/workspace/use-workflow-import.ts new file mode 100644 index 0000000000..59e1eb726d --- /dev/null +++ b/apps/web/app/settings/workspace/use-workflow-import.ts @@ -0,0 +1,477 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type ChangeEvent, type RefObject } from "react"; +import { useTranslation } from "react-i18next"; +import { importWorkflowsAction, previewWorkflowImportAction } from "@/app/actions/workspaces"; +import { useToast } from "@/components/toast-provider"; +import { useRouter } from "@/lib/routing/client-router"; +import type { + WorkflowImportPreview, + WorkflowImportProfileBinding, + WorkflowImportProfileCandidate, + WorkflowImportProfileConflict, + WorkflowImportProfilesRequiredResponse, + WorkflowImportProfileStep, + ImportWorkflowsResult, + Workspace, +} from "@/lib/types/http"; + +export type WorkflowImportPhase = "editing" | "previewing" | "resolving" | "submitting"; +export type WorkflowImportSelections = Record; + +export function workflowImportStepKey( + step: Pick, +): string { + return `${step.workflow_index}:${step.step_position}`; +} + +function workflowImportConflictKey(conflict: WorkflowImportProfileConflict): string { + return workflowImportStepKey(conflict); +} + +export function workflowImportProfileLabel(profile: WorkflowImportProfileCandidate): string { + return [profile.name, profile.agent_name, profile.model, profile.mode] + .filter(Boolean) + .join(" · "); +} + +export function workflowImportMissingSteps( + preview: WorkflowImportPreview | null, + selections: WorkflowImportSelections, +): WorkflowImportProfileStep[] { + if (!preview) return []; + return preview.steps.filter((step) => !selections[workflowImportStepKey(step)]); +} + +function sameCandidateRevision( + previous: WorkflowImportPreview | null, + next: WorkflowImportPreview, + profileId: string, +): boolean { + const previousProfile = previous?.profiles.find((profile) => profile.id === profileId); + const nextProfile = next.profiles.find((profile) => profile.id === profileId); + return Boolean( + previousProfile && nextProfile && previousProfile.updated_at === nextProfile.updated_at, + ); +} + +export function reconcileWorkflowImportSelections( + previous: WorkflowImportPreview | null, + next: WorkflowImportPreview, + selections: WorkflowImportSelections, +): WorkflowImportSelections { + const nextSelections: WorkflowImportSelections = {}; + for (const step of next.steps) { + const key = workflowImportStepKey(step); + const previousSelection = selections[key]; + if (previousSelection && sameCandidateRevision(previous, next, previousSelection)) { + nextSelections[key] = previousSelection; + continue; + } + if (previousSelection) continue; + if (step.matched_profile) nextSelections[key] = step.matched_profile.id; + } + return nextSelections; +} + +function buildWorkflowImportBindings( + preview: WorkflowImportPreview, + selections: WorkflowImportSelections, +): WorkflowImportProfileBinding[] { + return preview.steps.flatMap((step) => { + const profileId = selections[workflowImportStepKey(step)]; + const profile = preview.profiles.find((candidate) => candidate.id === profileId); + if (!profileId || !profile) return []; + return [ + { + workflow_index: step.workflow_index, + step_position: step.step_position, + requested_profile: step.requested_profile, + profile_id: profile.id, + profile_updated_at: profile.updated_at, + }, + ]; + }); +} + +export function getWorkflowImportConflict( + error: unknown, +): WorkflowImportProfilesRequiredResponse | null { + if (!error || typeof error !== "object") return null; + const body = "body" in error ? (error as { body?: unknown }).body : error; + if (!body || typeof body !== "object" || !("code" in body)) return null; + const candidate = body as Partial; + if (candidate.code !== "workflow_import_profiles_required" || !Array.isArray(candidate.steps)) { + return null; + } + return candidate as WorkflowImportProfilesRequiredResponse; +} + +type WorkflowImportArgs = { + workspace: Workspace | null; + router: ReturnType; + toast: ReturnType["toast"]; +}; + +function clearConflictedSelections( + current: WorkflowImportSelections, + conflicts: WorkflowImportProfileConflict[], +): WorkflowImportSelections { + const conflictedKeys = new Set(conflicts.map(workflowImportConflictKey)); + return Object.fromEntries(Object.entries(current).filter(([key]) => !conflictedKeys.has(key))); +} + +function importResultDescription( + result: ImportWorkflowsResult, + translate: (key: string, options?: Record) => string, +): string { + const parts: string[] = []; + if (result.created?.length) { + parts.push(translate("workflows:importCreated", { names: result.created.join(", ") })); + } + if (result.skipped?.length) { + parts.push(translate("workflows:importSkipped", { names: result.skipped.join(", ") })); + } + return parts.join(". "); +} + +function notifyImportFailure( + toast: WorkflowImportArgs["toast"], + translate: (key: string) => string, + error: unknown, +): void { + toast({ + title: translate("workflows:failedToImportWorkflows"), + description: error instanceof Error ? error.message : translate("workflows:invalidYaml"), + variant: "error", + }); +} + +function useWorkflowImportState(workspaceId: string | undefined) { + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); + const [importYaml, setImportYamlState] = useState(""); + const [phase, setPhase] = useState("editing"); + const [preview, setPreview] = useState(null); + const [selections, setSelections] = useState({}); + const [profileConflicts, setProfileConflicts] = useState([]); + const [activeStepKey, setActiveStepKey] = useState(null); + const fileInputRef = useRef(null); + const generationRef = useRef(0); + const submissionRef = useRef(false); + const workspaceIdRef = useRef(workspaceId); + + const clearReview = useCallback(() => { + setPreview(null); + setSelections({}); + setProfileConflicts([]); + setActiveStepKey(null); + setPhase("editing"); + }, []); + + const invalidatePreview = useCallback(() => { + if (submissionRef.current) return; + generationRef.current += 1; + clearReview(); + }, [clearReview]); + + useEffect(() => { + if (workspaceIdRef.current === workspaceId) return; + workspaceIdRef.current = workspaceId; + invalidatePreview(); + }, [invalidatePreview, workspaceId]); + + useEffect(() => { + return () => { + generationRef.current += 1; + }; + }, []); + + const setImportYaml = useCallback( + (value: string) => { + if (submissionRef.current) return; + setImportYamlState(value); + invalidatePreview(); + }, + [invalidatePreview], + ); + + const handleFileUpload = useCallback( + (event: ChangeEvent) => { + if (submissionRef.current) return; + const file = event.target.files?.[0]; + if (!file) return; + const generation = ++generationRef.current; + clearReview(); + const reader = new FileReader(); + reader.onload = (loadEvent) => { + if (generationRef.current !== generation) return; + setImportYamlState(String(loadEvent.target?.result ?? "")); + }; + reader.readAsText(file); + event.target.value = ""; + }, + [clearReview], + ); + + const cancelPendingImport = useCallback(() => { + if (submissionRef.current) return; + generationRef.current += 1; + submissionRef.current = false; + clearReview(); + }, [clearReview]); + + const resetAfterSuccess = useCallback(() => { + generationRef.current += 1; + submissionRef.current = false; + setIsImportDialogOpen(false); + setImportYamlState(""); + clearReview(); + }, [clearReview]); + + return { + isImportDialogOpen, + setIsImportDialogOpen, + importYaml, + phase, + setPhase, + preview, + setPreview, + selections, + setSelections, + profileConflicts, + setProfileConflicts, + activeStepKey, + setActiveStepKey, + fileInputRef, + generationRef, + submissionRef, + setImportYaml, + handleFileUpload, + cancelPendingImport, + resetAfterSuccess, + }; +} + +type WorkflowImportState = ReturnType; + +function useWorkflowImportSubmission({ + workspace, + router, + toast, + translate, + importYaml, + state, +}: WorkflowImportArgs & { + translate: (key: string, options?: Record) => string; + importYaml: string; + state: WorkflowImportState; +}) { + return useCallback( + async ( + targetPreview: WorkflowImportPreview, + targetSelections: WorkflowImportSelections, + generation: number, + ) => { + const { generationRef, submissionRef, setPhase, setActiveStepKey } = state; + if (submissionRef.current || generationRef.current !== generation || !workspace) return; + const missing = workflowImportMissingSteps(targetPreview, targetSelections); + if (missing.length > 0) { + setPhase("resolving"); + setActiveStepKey(null); + return; + } + submissionRef.current = true; + setPhase("submitting"); + try { + const result = await importWorkflowsAction( + workspace.id, + importYaml.trim(), + buildWorkflowImportBindings(targetPreview, targetSelections), + ); + if (generationRef.current !== generation) return; + toast({ + title: translate("workflows:importCompleteTitle"), + description: importResultDescription(result, translate), + }); + state.resetAfterSuccess(); + if (result.created?.length) router.refresh(); + } catch (error) { + if (generationRef.current !== generation) return; + const conflict = getWorkflowImportConflict(error); + if (conflict) { + state.setProfileConflicts(conflict.steps); + state.setSelections((current) => clearConflictedSelections(current, conflict.steps)); + setPhase("resolving"); + return; + } + notifyImportFailure(toast, translate, error); + setPhase("editing"); + } finally { + if (generationRef.current === generation) submissionRef.current = false; + } + }, + [importYaml, router, state, toast, translate, workspace], + ); +} + +function useWorkflowImportPreview({ + workspace, + importYaml, + preview, + selections, + profileConflicts, + state, + submit, + toast, + translate, +}: { + workspace: Workspace | null; + importYaml: string; + preview: WorkflowImportPreview | null; + selections: WorkflowImportSelections; + profileConflicts: WorkflowImportProfileConflict[]; + state: WorkflowImportState; + submit: ( + targetPreview: WorkflowImportPreview, + targetSelections: WorkflowImportSelections, + generation: number, + ) => Promise; + toast: WorkflowImportArgs["toast"]; + translate: (key: string) => string; +}) { + return useCallback(async () => { + const { + generationRef, + submissionRef, + setPhase, + setProfileConflicts, + setPreview, + setSelections, + setActiveStepKey, + } = state; + if (!workspace || !importYaml.trim() || submissionRef.current) return; + const generation = ++generationRef.current; + setPhase("previewing"); + setProfileConflicts([]); + try { + const nextPreview = await previewWorkflowImportAction(workspace.id, importYaml.trim()); + if (generationRef.current !== generation) return; + const nextSelections = clearConflictedSelections( + reconcileWorkflowImportSelections(preview, nextPreview, selections), + profileConflicts, + ); + setPreview(nextPreview); + setSelections(nextSelections); + const missing = workflowImportMissingSteps(nextPreview, nextSelections); + if (missing.length > 0) { + setPhase("resolving"); + setActiveStepKey(null); + return; + } + await submit(nextPreview, nextSelections, generation); + } catch (error) { + if (generationRef.current !== generation) return; + notifyImportFailure(toast, translate, error); + setPhase("editing"); + } + }, [ + importYaml, + preview, + profileConflicts, + selections, + state, + submit, + toast, + translate, + workspace, + ]); +} + +export function useWorkflowImport({ workspace, router, toast }: WorkflowImportArgs) { + const { t } = useTranslation(); + const state = useWorkflowImportState(workspace?.id); + const { importYaml, preview, selections, phase, submissionRef } = state; + const submit = useWorkflowImportSubmission({ + workspace, + router, + toast, + translate: t, + importYaml, + state, + }); + const runPreview = useWorkflowImportPreview({ + workspace, + importYaml, + preview, + selections, + profileConflicts: state.profileConflicts, + state, + submit, + toast, + translate: t, + }); + + const handleImport = useCallback(async () => { + if (!workspace || !importYaml.trim() || submissionRef.current) return; + if (preview) { + await submit(preview, selections, state.generationRef.current); + return; + } + await runPreview(); + }, [ + importYaml, + preview, + runPreview, + selections, + state.generationRef, + submit, + submissionRef, + workspace, + ]); + + const selectProfile = useCallback( + (stepKey: string, profileId: string) => { + state.setSelections((current) => ({ ...current, [stepKey]: profileId })); + state.setProfileConflicts((current) => + current.filter((conflict) => workflowImportStepKey(conflict) !== stepKey), + ); + state.setActiveStepKey(null); + }, + [state], + ); + + const setDialogOpen = useCallback( + (open: boolean) => { + if (open) { + if (!state.isImportDialogOpen && state.preview) state.cancelPendingImport(); + state.setIsImportDialogOpen(true); + return; + } + if (phase === "submitting" || submissionRef.current) return; + state.cancelPendingImport(); + state.setIsImportDialogOpen(open); + }, + [phase, state, submissionRef], + ); + + const missingSteps = workflowImportMissingSteps(preview, selections); + return { + isImportDialogOpen: state.isImportDialogOpen, + setIsImportDialogOpen: setDialogOpen, + importYaml, + setImportYaml: state.setImportYaml, + handleFileUpload: state.handleFileUpload, + fileInputRef: state.fileInputRef as RefObject, + handleImport, + importLoading: phase === "previewing" || phase === "submitting", + importPhase: phase, + preview, + selections, + missingSteps, + profileConflicts: state.profileConflicts, + activeStepKey: state.activeStepKey, + setActiveStepKey: state.setActiveStepKey, + selectProfile, + retryPreview: runPreview, + }; +} diff --git a/apps/web/app/settings/workspace/workflow-import-profile-selection-mobile-header.tsx b/apps/web/app/settings/workspace/workflow-import-profile-selection-mobile-header.tsx new file mode 100644 index 0000000000..0cf294aef4 --- /dev/null +++ b/apps/web/app/settings/workspace/workflow-import-profile-selection-mobile-header.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { IconChevronLeft } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { DrawerDescription, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer"; +import type { WorkflowImportProfileStep } from "@/lib/types/http"; + +export function MobileSelectionHeader({ + mobileView, + activeStep, + onBack, +}: { + mobileView: "list" | "picker"; + activeStep?: WorkflowImportProfileStep; + onBack: () => void; +}) { + const { t } = useTranslation(); + return ( + +
+ {mobileView === "picker" && ( + + )} +
+ + {mobileView === "picker" + ? t("workflows:importProfilePickerTitle") + : t("workflows:importResolveProfilesTitle")} + + + {mobileView === "picker" && activeStep + ? t("workflows:importProfilePickerDescription", { + stepName: activeStep.step_name, + }) + : t("workflows:importResolveProfilesDescription")} + +
+
+
+ ); +} diff --git a/apps/web/app/settings/workspace/workflow-import-profile-selection.test.tsx b/apps/web/app/settings/workspace/workflow-import-profile-selection.test.tsx new file mode 100644 index 0000000000..c86d251ed4 --- /dev/null +++ b/apps/web/app/settings/workspace/workflow-import-profile-selection.test.tsx @@ -0,0 +1,323 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { importWorkflowsAction, previewWorkflowImportAction } from "@/app/actions/workspaces"; +import type { WorkflowImportPreview, Workspace } from "@/lib/types/http"; +import { ImportWorkflowsDialog } from "./workspace-workflows-dialogs"; +import { WorkflowImportProfileSelection } from "./workflow-import-profile-selection"; +import { + reconcileWorkflowImportSelections, + useWorkflowImport, + workflowImportMissingSteps, + workflowImportProfileLabel, + workflowImportStepKey, +} from "./use-workflow-import"; + +vi.mock("@/app/actions/workspaces", () => ({ + importWorkflowsAction: vi.fn(), + previewWorkflowImportAction: vi.fn(), +})); + +const responsive = { isMobile: false, isFinePointer: true, usesTouchDrawer: false }; + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => responsive, +})); + +vi.mock("@/hooks/use-compact-task-chrome", () => ({ + useTouchDrawer: () => responsive.usesTouchDrawer, +})); + +afterEach(() => { + cleanup(); + responsive.isMobile = false; + responsive.isFinePointer = true; + responsive.usesTouchDrawer = false; +}); + +const preview: WorkflowImportPreview = { + skipped: [], + profiles: [ + { + id: "profile-1", + name: "Local Codex", + agent_name: "Codex", + model: "gpt-5", + mode: "full", + updated_at: "2026-09-17T13:00:00Z", + }, + ], + steps: [ + { + workflow_index: 2, + workflow_name: "Imported", + step_position: 4, + step_name: "Implement", + requested_profile: { agent_name: "Codex", model: "gpt-5", mode: "full" }, + matched_profile: { id: "profile-1", updated_at: "2026-09-17T13:00:00Z" }, + }, + ], +}; + +const workspace = { id: "workspace-1", name: "Workspace" } as Workspace; + +function DeferredSubmitDialog() { + const page = useWorkflowImport({ + workspace, + router: { refresh: vi.fn() } as never, + toast: vi.fn(), + }); + return ( + <> + + + + ); +} + +it("uses workflow index and step position as an independent key", () => { + expect(workflowImportStepKey(preview.steps[0])).toBe("2:4"); + expect(workflowImportMissingSteps(preview, {})).toEqual(preview.steps); +}); + +it("includes all profile identity fields in the searchable label", () => { + expect(workflowImportProfileLabel(preview.profiles[0])).toContain("Local Codex"); + expect(workflowImportProfileLabel(preview.profiles[0])).toContain("Codex"); + expect(workflowImportProfileLabel(preview.profiles[0])).toContain("gpt-5"); + expect(workflowImportProfileLabel(preview.profiles[0])).toContain("full"); +}); + +it("retains a selected profile only when its revision is unchanged", () => { + const selected = { "2:4": "profile-1" }; + expect(reconcileWorkflowImportSelections(preview, preview, selected)).toEqual(selected); + + const changed = { + ...preview, + profiles: [{ ...preview.profiles[0], updated_at: "2026-09-17T14:00:00Z" }], + }; + expect(reconcileWorkflowImportSelections(preview, changed, selected)).toEqual({}); +}); + +it("renders settings access and retry for an empty eligible catalog on desktop and phone", () => { + const emptyPreview: WorkflowImportPreview = { + skipped: [], + profiles: [], + steps: [ + { + workflow_index: 0, + workflow_name: "Imported", + step_position: 1, + step_name: "Implement", + requested_profile: { agent_name: "Missing", model: "model", mode: "mode" }, + }, + ], + }; + const props = { + open: true, + onOpenChange: vi.fn(), + preview: emptyPreview, + selections: {}, + missingSteps: emptyPreview.steps, + profileConflicts: [], + activeStepKey: "0:1", + onActiveStepKeyChange: vi.fn(), + onSelectProfile: vi.fn(), + onImport: vi.fn(), + onRetryPreview: vi.fn(), + importLoading: false, + }; + + for (const isMobile of [false, true]) { + responsive.isMobile = isMobile; + render(); + + const emptyState = screen.getByTestId("workflow-import-no-profiles"); + expect(emptyState).toBeTruthy(); + expect(screen.getByRole("link", { name: "Open agent profile settings" })).toBeTruthy(); + expect(within(emptyState).getByRole("button", { name: "Retry preview" })).toBeTruthy(); + cleanup(); + } +}); + +it("uses the touch drawer for coarse-pointer tablet layouts", () => { + responsive.isMobile = false; + responsive.isFinePointer = false; + responsive.usesTouchDrawer = true; + + render( + , + ); + + expect(document.querySelector('[data-slot="drawer-content"]')).not.toBeNull(); + expect(screen.getByTestId("workflow-import-profile-select-2:4").className).toContain("min-h-11"); +}); + +it("keeps the import action name stable and scopes busy status to one live region", () => { + render( + , + ); + + expect(screen.getByTestId("workflow-import-profile-submit").textContent).toContain( + "Import workflows", + ); + expect(screen.getByRole("status").textContent).toContain("Importing..."); + expect(document.querySelectorAll('[aria-live="polite"]')).toHaveLength(1); +}); + +it("keeps the selection surface mounted and draft controls unavailable while submitting", () => { + const submittingPreview: WorkflowImportPreview = { + ...preview, + steps: preview.steps.map((step) => ({ + ...step, + matched_profile: { + id: preview.profiles[0].id, + updated_at: preview.profiles[0].updated_at, + }, + })), + }; + const onImport = vi.fn(); + + for (const isMobile of [false, true]) { + responsive.isMobile = isMobile; + render( + , + ); + + expect(screen.getByTestId("workflow-import-profile-selection")).toBeTruthy(); + expect(screen.queryByRole("textbox")).toBeNull(); + + const submit = screen.getByTestId("workflow-import-profile-submit"); + expect(submit.hasAttribute("disabled")).toBe(true); + fireEvent.click(submit); + fireEvent.click(submit); + cleanup(); + } + expect(onImport).not.toHaveBeenCalled(); +}); + +it("keeps the rendered selection surface locked during a deferred final submit", async () => { + let resolveImport!: (value: { created: string[]; skipped: string[] }) => void; + vi.mocked(previewWorkflowImportAction).mockResolvedValueOnce(preview); + vi.mocked(importWorkflowsAction).mockReturnValueOnce( + new Promise((resolve) => { + resolveImport = resolve; + }), + ); + + render(); + fireEvent.click(screen.getByTestId("open-import")); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "portable yaml" } }); + fireEvent.click(screen.getByRole("button", { name: /^Import$/ })); + + await waitFor(() => expect(importWorkflowsAction).toHaveBeenCalledOnce()); + expect(screen.getByTestId("workflow-import-profile-selection")).toBeTruthy(); + expect(screen.queryByRole("textbox")).toBeNull(); + + const submit = screen.getByTestId("workflow-import-profile-submit"); + expect(submit.hasAttribute("disabled")).toBe(true); + fireEvent.click(submit); + fireEvent.click(submit); + expect(importWorkflowsAction).toHaveBeenCalledOnce(); + + resolveImport({ created: ["Imported"], skipped: [] }); + await waitFor(() => expect(screen.queryByTestId("workflow-import-profile-selection")).toBeNull()); +}); + +it("disables draft inputs while the initial preview is pending", () => { + render( + , + ); + + expect((screen.getByRole("textbox") as HTMLTextAreaElement).disabled).toBe(true); + expect((document.querySelector('input[type="file"]') as HTMLInputElement).disabled).toBe(true); +}); diff --git a/apps/web/app/settings/workspace/workflow-import-profile-selection.tsx b/apps/web/app/settings/workspace/workflow-import-profile-selection.tsx new file mode 100644 index 0000000000..c7155b4768 --- /dev/null +++ b/apps/web/app/settings/workspace/workflow-import-profile-selection.tsx @@ -0,0 +1,609 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconAlertTriangle, IconCheck, IconExternalLink, IconSelector } from "@tabler/icons-react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@kandev/ui/command"; +import { Button } from "@kandev/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; +import { Drawer, DrawerContent, DrawerFooter } from "@kandev/ui/drawer"; +import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; +import type { + WorkflowImportPreview, + WorkflowImportProfileCandidate, + WorkflowImportProfileConflict, + WorkflowImportProfileStep, +} from "@/lib/types/http"; +import { + workflowImportProfileLabel, + workflowImportStepKey, + type WorkflowImportSelections, +} from "./use-workflow-import"; +import { MobileSelectionHeader } from "./workflow-import-profile-selection-mobile-header"; + +type WorkflowImportProfileSelectionProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + preview: WorkflowImportPreview; + selections: WorkflowImportSelections; + missingSteps: WorkflowImportProfileStep[]; + profileConflicts: WorkflowImportProfileConflict[]; + activeStepKey: string | null; + onActiveStepKeyChange: (key: string | null) => void; + onSelectProfile: (stepKey: string, profileId: string) => void; + onImport: () => void | Promise; + onRetryPreview: () => void | Promise; + importLoading: boolean; +}; + +const DESKTOP_CONTROL_SIZE_CLASS = "min-h-11 md:min-h-7"; +const TOUCH_CONTROL_SIZE_CLASS = "min-h-11"; + +function profileField(value: string, translate: (key: string) => string): string { + return value || translate("workflows:importProfileNotSet"); +} + +function ProfileDetails({ + profile, + compact = false, +}: { + profile: WorkflowImportProfileCandidate; + compact?: boolean; +}) { + const { t } = useTranslation(); + return ( + + {profile.name || profile.id} + + {profileField(profile.agent_name, t)} · {profileField(profile.model, t)} ·{" "} + {profileField(profile.mode, t)} + + + ); +} + +function RequestedProfileDetails({ step }: { step: WorkflowImportProfileStep }) { + const { t } = useTranslation(); + const requested = step.requested_profile; + return ( + + + {t("workflows:importRequestedProfile")} + + {profileField(requested.agent_name, t)} · {profileField(requested.model ?? "", t)} ·{" "} + {profileField(requested.mode ?? "", t)} + + ); +} + +function ProfilePickerList({ + profiles, + selectedProfileId, + onSelect, +}: { + profiles: WorkflowImportProfileCandidate[]; + selectedProfileId?: string; + onSelect: (profileId: string) => void; +}) { + const { t } = useTranslation(); + return ( + + + + {t("workflows:importProfileNoMatches")} + + {profiles.map((profile) => ( + onSelect(profile.id)} + className="min-h-11 cursor-pointer gap-2" + data-testid={`workflow-import-profile-option-${profile.id}`} + > + + ))} + + + + ); +} + +function conflictForStep( + conflicts: WorkflowImportProfileConflict[], + step: WorkflowImportProfileStep, +): WorkflowImportProfileConflict | undefined { + const key = workflowImportStepKey(step); + return conflicts.find((conflict) => workflowImportStepKey(conflict) === key); +} + +function ConflictMessage({ conflict }: { conflict?: WorkflowImportProfileConflict }) { + const { t } = useTranslation(); + if (!conflict) return null; + const messageKey = + { + changed_profile: "workflows:importProfileChanged", + unavailable_profile: "workflows:importProfileUnavailable", + missing_selection: "workflows:importProfileSelectionRequired", + }[conflict.reason] ?? "workflows:importProfileSelectionRequired"; + return ( +

+

+ ); +} + +function SelectionRow({ + step, + preview, + selections, + conflicts, + mobile, + open, + onOpenChange, + onSelectProfile, +}: { + step: WorkflowImportProfileStep; + preview: WorkflowImportPreview; + selections: WorkflowImportSelections; + conflicts: WorkflowImportProfileConflict[]; + mobile: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + onSelectProfile: (profileId: string) => void; +}) { + const { t } = useTranslation(); + const triggerRef = useRef(null); + const key = workflowImportStepKey(step); + const selectedProfileId = selections[key]; + const selectedProfile = preview.profiles.find((profile) => profile.id === selectedProfileId); + const isAutomatic = step.matched_profile?.id === selectedProfileId; + const conflict = conflictForStep(conflicts, step); + const profileButton = ( + + ); + let profileContent = profileButton; + if (!mobile) { + profileContent = ( + + {profileButton} + { + event.preventDefault(); + triggerRef.current?.focus({ preventScroll: true }); + }} + > + + + + ); + } + return ( +
+
+

{step.step_name}

+ +
+ {isAutomatic && selectedProfile ? ( +
+
+ ) : ( + profileContent + )} + +
+ ); +} + +function SelectionRows({ + preview, + selections, + conflicts, + mobile, + openStepKey, + onOpenStep, + onSelectProfile, +}: { + preview: WorkflowImportPreview; + selections: WorkflowImportSelections; + conflicts: WorkflowImportProfileConflict[]; + mobile: boolean; + openStepKey: string | null; + onOpenStep: (key: string | null) => void; + onSelectProfile: (stepKey: string, profileId: string) => void; +}) { + const groups = new Map(); + for (const step of preview.steps) { + const group = groups.get(step.workflow_index) ?? { name: step.workflow_name, steps: [] }; + group.steps.push(step); + groups.set(step.workflow_index, group); + } + return ( +
+ {Array.from(groups.entries()).map(([workflowIndex, group]) => ( +
+

{group.name}

+
+ {group.steps.map((step) => { + const key = workflowImportStepKey(step); + return ( + onOpenStep(open ? key : null)} + onSelectProfile={(profileId) => onSelectProfile(key, profileId)} + /> + ); + })} +
+
+ ))} +
+ ); +} + +function EmptyProfileCallout({ + onRetry, + touch, +}: { + onRetry: () => void | Promise; + touch: boolean; +}) { + const { t } = useTranslation(); + return ( +
+

{t("workflows:importNoEligibleProfiles")}

+
+ + {t("workflows:openAgentSettings")} + + +
+
+ ); +} + +function SelectionFooter({ + canImport, + importLoading, + showRetry, + touch, + onOpenChange, + onImport, + onRetry, +}: { + canImport: boolean; + importLoading: boolean; + showRetry: boolean; + touch: boolean; + onOpenChange: (open: boolean) => void; + onImport: () => void | Promise; + onRetry: () => void | Promise; +}) { + const { t } = useTranslation(); + return ( + <> + + {showRetry && ( + + )} + + + {importLoading ? t("workflows:importing") : ""} + + + ); +} + +function MobileSelectionBody({ + mobileView, + activeStep, + activeStepKey, + preview, + selections, + missingSteps, + profileConflicts, + onRetryPreview, + onOpenMobilePicker, + onSelectMobileProfile, + onSelectProfile, +}: { + mobileView: "list" | "picker"; + activeStep?: WorkflowImportProfileStep; + activeStepKey: string | null; + preview: WorkflowImportPreview; + selections: WorkflowImportSelections; + missingSteps: WorkflowImportProfileStep[]; + profileConflicts: WorkflowImportProfileConflict[]; + onRetryPreview: () => void | Promise; + onOpenMobilePicker: (key: string | null) => void; + onSelectMobileProfile: (profileId: string) => void; + onSelectProfile: (stepKey: string, profileId: string) => void; +}) { + if (mobileView === "picker" && activeStep) { + return ( + + ); + } + return ( + <> + {preview.profiles.length === 0 && missingSteps.length > 0 && ( + + )} + + + ); +} + +function MobileProfileSelection({ + open, + onOpenChange, + preview, + selections, + missingSteps, + profileConflicts, + activeStepKey, + onActiveStepKeyChange, + onSelectProfile, + onImport, + onRetryPreview, + importLoading, +}: WorkflowImportProfileSelectionProps) { + const [mobileView, setMobileView] = useState<"list" | "picker">("list"); + useEffect(() => { + if (open) setMobileView("list"); + }, [open]); + const activeStep = preview.steps.find((step) => workflowImportStepKey(step) === activeStepKey); + const openMobilePicker = (key: string | null) => { + onActiveStepKeyChange(key); + if (key) setMobileView("picker"); + else setMobileView("list"); + }; + const selectMobileProfile = (profileId: string) => { + if (!activeStepKey) return; + onSelectProfile(activeStepKey, profileId); + setMobileView("list"); + }; + return ( + + { + if (mobileView === "picker") { + event.preventDefault(); + openMobilePicker(null); + } + }} + > +
+ openMobilePicker(null)} + /> +
+ +
+ + 0 || + (preview.profiles.length === 0 && missingSteps.length > 0) + } + onOpenChange={onOpenChange} + onImport={onImport} + onRetry={onRetryPreview} + /> + +
+
+
+ ); +} + +function DesktopProfileSelection({ + open, + onOpenChange, + preview, + selections, + missingSteps, + profileConflicts, + activeStepKey, + onActiveStepKeyChange, + onSelectProfile, + onImport, + onRetryPreview, + importLoading, +}: WorkflowImportProfileSelectionProps) { + const { t } = useTranslation(); + const canImport = missingSteps.length === 0; + return ( + + + + {t("workflows:importResolveProfilesTitle")} +

+ {t("workflows:importResolveProfilesDescription")} +

+
+
+ {preview.profiles.length === 0 && missingSteps.length > 0 && ( + + )} + +
+ + 0} + onOpenChange={onOpenChange} + onImport={onImport} + onRetry={onRetryPreview} + /> + +
+
+ ); +} + +export function WorkflowImportProfileSelection(props: WorkflowImportProfileSelectionProps) { + const { isMobile } = useResponsiveBreakpoint(); + const usesTouchDrawer = useTouchDrawer(); + return isMobile || usesTouchDrawer ? ( + + ) : ( + + ); +} diff --git a/apps/web/app/settings/workspace/workspace-workflows-client.tsx b/apps/web/app/settings/workspace/workspace-workflows-client.tsx index 2fe5e1485b..29608094c1 100644 --- a/apps/web/app/settings/workspace/workspace-workflows-client.tsx +++ b/apps/web/app/settings/workspace/workspace-workflows-client.tsx @@ -31,7 +31,6 @@ import { useWorkflowSettings } from "@/hooks/domains/settings/use-workflow-setti import { deleteWorkflowAction, exportAllWorkflowsAction, - importWorkflowsAction, reorderWorkflowsAction, } from "@/app/actions/workspaces"; import { @@ -43,6 +42,7 @@ import { } from "@/lib/types/http"; import { WorkflowDialogs } from "@/app/settings/workspace/workspace-workflows-dialogs"; import { useWorkflowCreation } from "@/app/settings/workspace/use-workflow-creation"; +import { useWorkflowImport } from "@/app/settings/workspace/use-workflow-import"; import { WorkspaceNotFoundCard } from "@/app/settings/workspace/workspace-not-found-card"; type WorkspaceWorkflowsClientProps = { @@ -81,10 +81,6 @@ function useWorkflowImportExport( ) { const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [exportYaml, setExportYaml] = useState(""); - const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); - const [importYaml, setImportYaml] = useState(""); - const [importLoading, setImportLoading] = useState(false); - const fileInputRef = useRef(null); const handleExportAll = async () => { if (!workspace) return; @@ -105,57 +101,11 @@ function useWorkflowImportExport( } }; - const handleFileUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = (event) => { - setImportYaml(event.target?.result as string); - }; - reader.readAsText(file); - e.target.value = ""; - }; - - const handleImport = async () => { - if (!workspace || !importYaml.trim()) return; - setImportLoading(true); - try { - const result = await importWorkflowsAction(workspace.id, importYaml.trim()); - const created = result.created ?? []; - const skipped = result.skipped ?? []; - const parts: string[] = []; - if (created.length > 0) - parts.push(translate("workflows:importCreated", { names: created.join(", ") })); - if (skipped.length > 0) - parts.push(translate("workflows:importSkipped", { names: skipped.join(", ") })); - toast({ title: translate("workflows:importCompleteTitle"), description: parts.join(". ") }); - setIsImportDialogOpen(false); - setImportYaml(""); - if (created.length > 0) router.refresh(); - } catch (error) { - toast({ - title: translate("workflows:failedToImportWorkflows"), - description: error instanceof Error ? error.message : translate("workflows:invalidYaml"), - variant: "error", - }); - } finally { - setImportLoading(false); - } - }; - return { isExportDialogOpen, setIsExportDialogOpen, exportYaml, - isImportDialogOpen, - setIsImportDialogOpen, - importYaml, - setImportYaml, - importLoading, - fileInputRef, handleExportAll, - handleFileUpload, - handleImport, }; } @@ -602,6 +552,7 @@ function useWorkspaceWorkflowsPage( const workflowIdMappingsRef = useRef(new Map()); const importExport = useWorkflowImportExport(workspace, workflowItems, router, toast); + const importState = useWorkflowImport({ workspace, router, toast }); const actions = useWorkflowActions({ workspace, workflowItems, @@ -638,6 +589,7 @@ function useWorkspaceWorkflowsPage( workflowOrderDirtyIds, isWorkflowDirty, ...importExport, + ...importState, ...actions, handleWorkflowSaved, handleReorderWorkflows, diff --git a/apps/web/app/settings/workspace/workspace-workflows-dialogs.tsx b/apps/web/app/settings/workspace/workspace-workflows-dialogs.tsx index 808a3375b8..a97df67e86 100644 --- a/apps/web/app/settings/workspace/workspace-workflows-dialogs.tsx +++ b/apps/web/app/settings/workspace/workspace-workflows-dialogs.tsx @@ -9,7 +9,14 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from " import { Textarea } from "@kandev/ui/textarea"; import { cn } from "@/lib/utils"; import { WorkflowExportDialog } from "@/components/settings/workflow-export-dialog"; -import type { WorkflowTemplate } from "@/lib/types/http"; +import type { + WorkflowImportPreview, + WorkflowImportProfileConflict, + WorkflowImportProfileStep, + WorkflowTemplate, +} from "@/lib/types/http"; +import { WorkflowImportProfileSelection } from "./workflow-import-profile-selection"; +import type { WorkflowImportPhase, WorkflowImportSelections } from "./use-workflow-import"; const YAML_PLACEHOLDER = "version: 2\ntype: kandev_workflow\nworkflows:\n - name: My Workflow\n steps: [...]"; @@ -21,8 +28,17 @@ type ImportWorkflowsDialogProps = { onImportYamlChange: (value: string) => void; onFileUpload: (e: React.ChangeEvent) => void; fileInputRef: React.RefObject; - onImport: () => void; + onImport: () => void | Promise; importLoading: boolean; + importPhase: WorkflowImportPhase; + preview: WorkflowImportPreview | null; + selections: WorkflowImportSelections; + missingSteps: WorkflowImportProfileStep[]; + profileConflicts: WorkflowImportProfileConflict[]; + activeStepKey: string | null; + setActiveStepKey: (key: string | null) => void; + selectProfile: (stepKey: string, profileId: string) => void; + retryPreview: () => void | Promise; }; export function ImportWorkflowsDialog({ @@ -34,38 +50,74 @@ export function ImportWorkflowsDialog({ fileInputRef, onImport, importLoading, + importPhase, + preview, + selections, + missingSteps, + profileConflicts, + activeStepKey, + setActiveStepKey, + selectProfile, + retryPreview, }: ImportWorkflowsDialogProps) { const { t } = useTranslation(); + if (preview && importPhase !== "editing") { + return ( + + ); + } return ( - + {t("workflows:importWorkflowsTitle")} -
+
- +
- +