feat: add workflow import profile selection (#3771)

This commit is contained in:
Carlos Florêncio
2026-09-18 00:34:50 +01:00
committed by GitHub
parent 0b41329dc3
commit 8966a6020b
35 changed files with 4313 additions and 90 deletions
@@ -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)
}
}
}
@@ -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 {
@@ -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,
}
}
@@ -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)
}
@@ -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)
@@ -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 &copy, 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())
}
})
}
@@ -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 {
@@ -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 {
@@ -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
@@ -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
}
@@ -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 &copy, 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)
})
}
}
@@ -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
@@ -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{
+23 -2
View File
@@ -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<T>(url: string, options?: RequestInit): Promise<T> {
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<T>(url: string, options?: RequestInit): Promise<T> {
// 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<ImportWorkflowsResult> {
return fetchJson<ImportWorkflowsResult>(
`${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<WorkflowImportPreview> {
return fetchJson<WorkflowImportPreview>(
`${apiBaseUrl}/api/v1/workspaces/${encodeURIComponent(workspaceId)}/workflows/import/preview`,
{
method: "POST",
headers: { "Content-Type": "application/x-yaml" },
@@ -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<void> | 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<FileReader>);
});
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<void> | 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;
});
});
@@ -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<string, string>;
export function workflowImportStepKey(
step: Pick<WorkflowImportProfileStep, "workflow_index" | "step_position">,
): 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<WorkflowImportProfilesRequiredResponse>;
if (candidate.code !== "workflow_import_profiles_required" || !Array.isArray(candidate.steps)) {
return null;
}
return candidate as WorkflowImportProfilesRequiredResponse;
}
type WorkflowImportArgs = {
workspace: Workspace | null;
router: ReturnType<typeof useRouter>;
toast: ReturnType<typeof useToast>["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, unknown>) => 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<WorkflowImportPhase>("editing");
const [preview, setPreview] = useState<WorkflowImportPreview | null>(null);
const [selections, setSelections] = useState<WorkflowImportSelections>({});
const [profileConflicts, setProfileConflicts] = useState<WorkflowImportProfileConflict[]>([]);
const [activeStepKey, setActiveStepKey] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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<typeof useWorkflowImportState>;
function useWorkflowImportSubmission({
workspace,
router,
toast,
translate,
importYaml,
state,
}: WorkflowImportArgs & {
translate: (key: string, options?: Record<string, unknown>) => 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<void>;
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<HTMLInputElement | null>,
handleImport,
importLoading: phase === "previewing" || phase === "submitting",
importPhase: phase,
preview,
selections,
missingSteps,
profileConflicts: state.profileConflicts,
activeStepKey: state.activeStepKey,
setActiveStepKey: state.setActiveStepKey,
selectProfile,
retryPreview: runPreview,
};
}
@@ -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 (
<DrawerHeader className="shrink-0 border-b border-border/70 pb-3 text-left">
<div className="flex items-center gap-2">
{mobileView === "picker" && (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="h-11 w-11 shrink-0 cursor-pointer"
aria-label={t("common:back")}
onClick={onBack}
>
<IconChevronLeft className="h-4 w-4" aria-hidden="true" />
</Button>
)}
<div className="min-w-0">
<DrawerTitle>
{mobileView === "picker"
? t("workflows:importProfilePickerTitle")
: t("workflows:importResolveProfilesTitle")}
</DrawerTitle>
<DrawerDescription>
{mobileView === "picker" && activeStep
? t("workflows:importProfilePickerDescription", {
stepName: activeStep.step_name,
})
: t("workflows:importResolveProfilesDescription")}
</DrawerDescription>
</div>
</div>
</DrawerHeader>
);
}
@@ -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 (
<>
<button
type="button"
data-testid="open-import"
onClick={() => page.setIsImportDialogOpen(true)}
>
Open import
</button>
<ImportWorkflowsDialog
open={page.isImportDialogOpen}
onOpenChange={page.setIsImportDialogOpen}
importYaml={page.importYaml}
onImportYamlChange={page.setImportYaml}
onFileUpload={page.handleFileUpload}
fileInputRef={page.fileInputRef}
onImport={page.handleImport}
importLoading={page.importLoading}
importPhase={page.importPhase}
preview={page.preview}
selections={page.selections}
missingSteps={page.missingSteps}
profileConflicts={page.profileConflicts}
activeStepKey={page.activeStepKey}
setActiveStepKey={page.setActiveStepKey}
selectProfile={page.selectProfile}
retryPreview={page.retryPreview}
/>
</>
);
}
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(<WorkflowImportProfileSelection {...props} />);
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(
<WorkflowImportProfileSelection
open
onOpenChange={vi.fn()}
preview={preview}
selections={{}}
missingSteps={preview.steps}
profileConflicts={[]}
activeStepKey={"2:4"}
onActiveStepKeyChange={vi.fn()}
onSelectProfile={vi.fn()}
onImport={vi.fn()}
onRetryPreview={vi.fn()}
importLoading={false}
/>,
);
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(
<ImportWorkflowsDialog
open
onOpenChange={vi.fn()}
importYaml="portable yaml"
onImportYamlChange={vi.fn()}
onFileUpload={vi.fn()}
fileInputRef={{ current: null }}
onImport={vi.fn()}
importLoading
importPhase="submitting"
preview={preview}
selections={{ "2:4": preview.profiles[0].id }}
missingSteps={[]}
profileConflicts={[]}
activeStepKey={null}
setActiveStepKey={vi.fn()}
selectProfile={vi.fn()}
retryPreview={vi.fn()}
/>,
);
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(
<ImportWorkflowsDialog
open
onOpenChange={vi.fn()}
importYaml="portable yaml"
onImportYamlChange={vi.fn()}
onFileUpload={vi.fn()}
fileInputRef={{ current: null }}
onImport={onImport}
importLoading
importPhase="submitting"
preview={submittingPreview}
selections={{ "0:0": preview.profiles[0].id, "0:1": preview.profiles[0].id }}
missingSteps={[]}
profileConflicts={[]}
activeStepKey={null}
setActiveStepKey={vi.fn()}
selectProfile={vi.fn()}
retryPreview={vi.fn()}
/>,
);
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(<DeferredSubmitDialog />);
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(
<ImportWorkflowsDialog
open
onOpenChange={vi.fn()}
importYaml="portable yaml"
onImportYamlChange={vi.fn()}
onFileUpload={vi.fn()}
fileInputRef={{ current: null }}
onImport={vi.fn()}
importLoading
importPhase="previewing"
preview={null}
selections={{}}
missingSteps={[]}
profileConflicts={[]}
activeStepKey={null}
setActiveStepKey={vi.fn()}
selectProfile={vi.fn()}
retryPreview={vi.fn()}
/>,
);
expect((screen.getByRole("textbox") as HTMLTextAreaElement).disabled).toBe(true);
expect((document.querySelector('input[type="file"]') as HTMLInputElement).disabled).toBe(true);
});
@@ -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<void>;
onRetryPreview: () => void | Promise<void>;
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 (
<span className={compact ? "flex min-w-0 flex-col text-left" : "flex min-w-0 flex-col gap-1"}>
<span className="truncate font-medium">{profile.name || profile.id}</span>
<span className="truncate text-xs text-muted-foreground">
{profileField(profile.agent_name, t)} · {profileField(profile.model, t)} ·{" "}
{profileField(profile.mode, t)}
</span>
</span>
);
}
function RequestedProfileDetails({ step }: { step: WorkflowImportProfileStep }) {
const { t } = useTranslation();
const requested = step.requested_profile;
return (
<span className="min-w-0 text-xs text-muted-foreground">
<span className="mr-1 font-medium text-foreground">
{t("workflows:importRequestedProfile")}
</span>
{profileField(requested.agent_name, t)} · {profileField(requested.model ?? "", t)} ·{" "}
{profileField(requested.mode ?? "", t)}
</span>
);
}
function ProfilePickerList({
profiles,
selectedProfileId,
onSelect,
}: {
profiles: WorkflowImportProfileCandidate[];
selectedProfileId?: string;
onSelect: (profileId: string) => void;
}) {
const { t } = useTranslation();
return (
<Command shouldFilter>
<CommandInput
autoFocus
placeholder={t("workflows:importProfileSearchPlaceholder")}
aria-label={t("workflows:importProfileSearchPlaceholder")}
/>
<CommandList className="max-h-[min(50vh,24rem)] overscroll-contain">
<CommandEmpty>{t("workflows:importProfileNoMatches")}</CommandEmpty>
<CommandGroup>
{profiles.map((profile) => (
<CommandItem
key={profile.id}
value={workflowImportProfileLabel(profile)}
keywords={[profile.id, profile.name, profile.agent_name, profile.model, profile.mode]}
onSelect={() => onSelect(profile.id)}
className="min-h-11 cursor-pointer gap-2"
data-testid={`workflow-import-profile-option-${profile.id}`}
>
<IconCheck
className={
selectedProfileId === profile.id
? "h-4 w-4 shrink-0"
: "h-4 w-4 shrink-0 opacity-0"
}
aria-hidden="true"
/>
<ProfileDetails profile={profile} compact />
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}
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 (
<p className="flex items-start gap-1.5 text-xs text-destructive" role="alert">
<IconAlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span>{t(messageKey)}</span>
</p>
);
}
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<HTMLButtonElement>(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 = (
<Button
type="button"
variant="outline"
ref={triggerRef}
disabled={preview.profiles.length === 0}
onClick={mobile ? () => onOpenChange(true) : undefined}
className={`${mobile ? TOUCH_CONTROL_SIZE_CLASS : DESKTOP_CONTROL_SIZE_CLASS} w-full cursor-pointer justify-start text-left`}
data-testid={`workflow-import-profile-select-${key}`}
>
{selectedProfile ? (
<span
className="min-w-0 flex-1 truncate"
title={workflowImportProfileLabel(selectedProfile)}
>
{workflowImportProfileLabel(selectedProfile)}
</span>
) : (
<span className="min-w-0 flex-1 truncate">{t("workflows:importSelectProfile")}</span>
)}
<IconSelector className="ml-auto h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
</Button>
);
let profileContent = profileButton;
if (!mobile) {
profileContent = (
<Popover modal={false} open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>{profileButton}</PopoverTrigger>
<PopoverContent
align="start"
className="w-[min(24rem,calc(100vw-2rem))] p-0"
onCloseAutoFocus={(event) => {
event.preventDefault();
triggerRef.current?.focus({ preventScroll: true });
}}
>
<ProfilePickerList
profiles={preview.profiles}
selectedProfileId={selectedProfileId}
onSelect={onSelectProfile}
/>
</PopoverContent>
</Popover>
);
}
return (
<div
className="space-y-2 rounded-lg border border-border/70 p-3"
data-testid={`workflow-import-profile-step-${key}`}
>
<div className="min-w-0 space-y-1">
<p className="truncate font-medium">{step.step_name}</p>
<RequestedProfileDetails step={step} />
</div>
{isAutomatic && selectedProfile ? (
<div
className="flex min-w-0 items-center gap-2 rounded-md bg-muted/50 p-2 text-sm"
data-testid={`workflow-import-profile-match-${key}`}
>
<IconCheck className="h-4 w-4 shrink-0 text-green-600" aria-hidden="true" />
<ProfileDetails profile={selectedProfile} compact />
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
{t("workflows:importProfileMatched")}
</span>
</div>
) : (
profileContent
)}
<ConflictMessage conflict={conflict} />
</div>
);
}
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<number, { name: string; steps: WorkflowImportProfileStep[] }>();
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 (
<div className="space-y-4">
{Array.from(groups.entries()).map(([workflowIndex, group]) => (
<section
key={workflowIndex}
className="space-y-2"
data-testid={`workflow-import-group-${workflowIndex}`}
>
<h3 className="font-medium">{group.name}</h3>
<div className="space-y-2">
{group.steps.map((step) => {
const key = workflowImportStepKey(step);
return (
<SelectionRow
key={key}
step={step}
preview={preview}
selections={selections}
conflicts={conflicts}
mobile={mobile}
open={openStepKey === key}
onOpenChange={(open) => onOpenStep(open ? key : null)}
onSelectProfile={(profileId) => onSelectProfile(key, profileId)}
/>
);
})}
</div>
</section>
))}
</div>
);
}
function EmptyProfileCallout({
onRetry,
touch,
}: {
onRetry: () => void | Promise<void>;
touch: boolean;
}) {
const { t } = useTranslation();
return (
<div
className="space-y-3 rounded-lg border border-dashed border-border p-4"
data-testid="workflow-import-no-profiles"
>
<p className="text-sm text-muted-foreground">{t("workflows:importNoEligibleProfiles")}</p>
<div className="flex flex-wrap items-center gap-3">
<a
href="/settings/agents"
target="_blank"
rel="noreferrer"
className={`${touch ? TOUCH_CONTROL_SIZE_CLASS : DESKTOP_CONTROL_SIZE_CLASS} inline-flex cursor-pointer items-center gap-1 text-sm text-primary underline-offset-4 hover:underline`}
>
{t("workflows:openAgentSettings")}
<IconExternalLink className="h-4 w-4" aria-hidden="true" />
</a>
<Button
type="button"
variant="outline"
className={`${touch ? TOUCH_CONTROL_SIZE_CLASS : DESKTOP_CONTROL_SIZE_CLASS} cursor-pointer`}
onClick={onRetry}
>
{t("workflows:retryImportPreview")}
</Button>
</div>
</div>
);
}
function SelectionFooter({
canImport,
importLoading,
showRetry,
touch,
onOpenChange,
onImport,
onRetry,
}: {
canImport: boolean;
importLoading: boolean;
showRetry: boolean;
touch: boolean;
onOpenChange: (open: boolean) => void;
onImport: () => void | Promise<void>;
onRetry: () => void | Promise<void>;
}) {
const { t } = useTranslation();
return (
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={importLoading}
className={`${touch ? TOUCH_CONTROL_SIZE_CLASS : DESKTOP_CONTROL_SIZE_CLASS} cursor-pointer`}
>
{t("common:cancel")}
</Button>
{showRetry && (
<Button
type="button"
variant="outline"
onClick={onRetry}
disabled={importLoading}
className={`${touch ? TOUCH_CONTROL_SIZE_CLASS : DESKTOP_CONTROL_SIZE_CLASS} cursor-pointer`}
>
{t("workflows:retryImportPreview")}
</Button>
)}
<Button
type="button"
onClick={onImport}
disabled={!canImport || importLoading}
className={`${touch ? TOUCH_CONTROL_SIZE_CLASS : DESKTOP_CONTROL_SIZE_CLASS} cursor-pointer`}
data-testid="workflow-import-profile-submit"
>
{t("workflows:importContinue")}
</Button>
<span className="sr-only" role="status" aria-live="polite">
{importLoading ? t("workflows:importing") : ""}
</span>
</>
);
}
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<void>;
onOpenMobilePicker: (key: string | null) => void;
onSelectMobileProfile: (profileId: string) => void;
onSelectProfile: (stepKey: string, profileId: string) => void;
}) {
if (mobileView === "picker" && activeStep) {
return (
<ProfilePickerList
profiles={preview.profiles}
selectedProfileId={selections[activeStepKey ?? ""]}
onSelect={onSelectMobileProfile}
/>
);
}
return (
<>
{preview.profiles.length === 0 && missingSteps.length > 0 && (
<EmptyProfileCallout onRetry={onRetryPreview} touch />
)}
<SelectionRows
preview={preview}
selections={selections}
conflicts={profileConflicts}
mobile
openStepKey={null}
onOpenStep={onOpenMobilePicker}
onSelectProfile={onSelectProfile}
/>
</>
);
}
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 (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent
className="h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] !max-h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] outline-none"
onEscapeKeyDown={(event) => {
if (mobileView === "picker") {
event.preventDefault();
openMobilePicker(null);
}
}}
>
<div
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl bg-background shadow-2xl shadow-black/20"
data-testid="workflow-import-profile-selection"
>
<MobileSelectionHeader
mobileView={mobileView}
activeStep={activeStep}
onBack={() => openMobilePicker(null)}
/>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-[env(safe-area-inset-bottom,0px)]">
<MobileSelectionBody
mobileView={mobileView}
activeStep={activeStep}
activeStepKey={activeStepKey}
preview={preview}
selections={selections}
missingSteps={missingSteps}
profileConflicts={profileConflicts}
onRetryPreview={onRetryPreview}
onOpenMobilePicker={openMobilePicker}
onSelectMobileProfile={selectMobileProfile}
onSelectProfile={onSelectProfile}
/>
</div>
<DrawerFooter className="shrink-0 border-t border-border/70 bg-background/95 pb-[calc(1rem+env(safe-area-inset-bottom,0px))]">
<SelectionFooter
canImport={missingSteps.length === 0}
importLoading={importLoading}
touch
showRetry={
profileConflicts.length > 0 ||
(preview.profiles.length === 0 && missingSteps.length > 0)
}
onOpenChange={onOpenChange}
onImport={onImport}
onRetry={onRetryPreview}
/>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
);
}
function DesktopProfileSelection({
open,
onOpenChange,
preview,
selections,
missingSteps,
profileConflicts,
activeStepKey,
onActiveStepKeyChange,
onSelectProfile,
onImport,
onRetryPreview,
importLoading,
}: WorkflowImportProfileSelectionProps) {
const { t } = useTranslation();
const canImport = missingSteps.length === 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex max-h-[min(90dvh,48rem)] flex-col overflow-hidden sm:max-w-3xl"
data-testid="workflow-import-profile-selection"
>
<DialogHeader>
<DialogTitle>{t("workflows:importResolveProfilesTitle")}</DialogTitle>
<p className="text-sm text-muted-foreground">
{t("workflows:importResolveProfilesDescription")}
</p>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{preview.profiles.length === 0 && missingSteps.length > 0 && (
<EmptyProfileCallout onRetry={onRetryPreview} touch={false} />
)}
<SelectionRows
preview={preview}
selections={selections}
conflicts={profileConflicts}
mobile={false}
openStepKey={activeStepKey}
onOpenStep={onActiveStepKeyChange}
onSelectProfile={onSelectProfile}
/>
</div>
<DialogFooter>
<SelectionFooter
canImport={canImport}
importLoading={importLoading}
touch={false}
showRetry={profileConflicts.length > 0}
onOpenChange={onOpenChange}
onImport={onImport}
onRetry={onRetryPreview}
/>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function WorkflowImportProfileSelection(props: WorkflowImportProfileSelectionProps) {
const { isMobile } = useResponsiveBreakpoint();
const usesTouchDrawer = useTouchDrawer();
return isMobile || usesTouchDrawer ? (
<MobileProfileSelection {...props} />
) : (
<DesktopProfileSelection {...props} />
);
}
@@ -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<HTMLInputElement>(null);
const handleExportAll = async () => {
if (!workspace) return;
@@ -105,57 +101,11 @@ function useWorkflowImportExport(
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
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<string, string>());
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,
@@ -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<HTMLInputElement>) => void;
fileInputRef: React.RefObject<HTMLInputElement | null>;
onImport: () => void;
onImport: () => void | Promise<void>;
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<void>;
};
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 (
<WorkflowImportProfileSelection
open={open}
onOpenChange={onOpenChange}
preview={preview}
selections={selections}
missingSteps={missingSteps}
profileConflicts={profileConflicts}
activeStepKey={activeStepKey}
onActiveStepKeyChange={setActiveStepKey}
onSelectProfile={selectProfile}
onImport={onImport}
onRetryPreview={retryPreview}
importLoading={importLoading}
/>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="flex max-h-[90dvh] flex-col overflow-hidden sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{t("workflows:importWorkflowsTitle")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="min-h-0 space-y-5 overflow-y-auto">
<div className="space-y-2">
<Label>{t("workflows:uploadYamlFile")}</Label>
<Label htmlFor="workflow-import-file">{t("workflows:uploadYamlFile")}</Label>
<input
id="workflow-import-file"
ref={fileInputRef}
type="file"
accept=".yml,.yaml"
onChange={onFileUpload}
className="block w-full text-sm text-muted-foreground file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground file:cursor-pointer cursor-pointer"
disabled={importLoading}
className="block w-full min-w-0 cursor-pointer text-sm text-muted-foreground file:mr-3 file:h-7 file:cursor-pointer file:rounded-md file:border file:border-input file:bg-muted file:px-3 file:py-0 file:text-xs file:font-medium file:text-foreground max-md:file:h-11 [@media(pointer:coarse)]:file:h-11"
/>
</div>
<div className="space-y-2">
<Label>{t("workflows:orPasteYaml")}</Label>
<Label htmlFor="workflow-import-yaml">{t("workflows:orPasteYaml")}</Label>
<Textarea
id="workflow-import-yaml"
// The placeholder is a sample of the kandev_workflow export
// payload — a wire format, so its keys stay untranslated.
placeholder={YAML_PLACEHOLDER}
value={importYaml}
onChange={(e) => onImportYamlChange(e.target.value)}
className="font-mono text-xs max-h-96 overflow-y-auto"
disabled={importLoading}
className="min-h-56 max-h-[50dvh] resize-y overflow-auto font-mono text-xs leading-relaxed"
/>
</div>
</div>
<DialogFooter>
<DialogFooter className="shrink-0">
{importLoading && (
<span className="sr-only" role="status" aria-live="polite">
{t("workflows:importing")}
</span>
)}
<Button variant="outline" onClick={() => onOpenChange(false)} className="cursor-pointer">
{t("common:cancel")}
</Button>
@@ -246,6 +298,15 @@ export function WorkflowDialogs({
fileInputRef: React.RefObject<HTMLInputElement | null>;
handleImport: () => Promise<void>;
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<void>;
isAddWorkflowDialogOpen: boolean;
setIsAddWorkflowDialogOpen: (open: boolean) => void;
newWorkflowName: string;
@@ -275,6 +336,15 @@ export function WorkflowDialogs({
fileInputRef={page.fileInputRef}
onImport={page.handleImport}
importLoading={page.importLoading}
importPhase={page.importPhase}
preview={page.preview}
selections={page.selections}
missingSteps={page.missingSteps}
profileConflicts={page.profileConflicts}
activeStepKey={page.activeStepKey}
setActiveStepKey={page.setActiveStepKey}
selectProfile={page.selectProfile}
retryPreview={page.retryPreview}
/>
<CreateWorkflowDialog
open={page.isAddWorkflowDialogOpen}
@@ -0,0 +1,126 @@
import { expect, test } from "../../fixtures/test-base";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { WorkflowSettingsPage } from "../../pages/workflow-settings-page";
test.describe("Workflow import profile selection on mobile", () => {
test("uses a drawer picker and preserves a later session target", async ({
testPage,
apiClient,
seedData,
}) => {
const replacement = await apiClient.getAgentProfile(seedData.agentProfileId);
const workflowName = `Mobile profile import ${Date.now()}`;
const yamlContent = `version: 2
type: kandev_workflow
workflows:
- name: ${workflowName}
steps:
- name: Start
position: 0
color: bg-neutral-400
events: {}
is_start_step: true
show_in_command_panel: true
allow_manual_move: true
complete_task_on_enter: false
auto_advance_requires_signal: false
cancel_triggers_turn_complete: false
- name: Implement
position: 1
color: bg-blue-500
events: {}
is_start_step: false
show_in_command_panel: true
allow_manual_move: true
agent_profile:
agent_name: Missing mobile import agent
model: missing-mobile-import-model
mode: missing-mobile-import-mode
complete_task_on_enter: false
auto_advance_requires_signal: false
cancel_triggers_turn_complete: false
- name: Review
position: 2
color: bg-yellow-500
events: {}
is_start_step: false
show_in_command_panel: true
allow_manual_move: true
session_target:
kind: step
step_position: 1
complete_task_on_enter: false
auto_advance_requires_signal: false
cancel_triggers_turn_complete: false`;
try {
const page = new WorkflowSettingsPage(testPage);
await page.goto(seedData.workspaceId);
await testPage.getByRole("button", { name: "Import", exact: true }).tap();
const importDialog = testPage.getByRole("dialog");
const fileButtonHeight = await importDialog
.locator('input[type="file"]')
.evaluate((input) => parseFloat(getComputedStyle(input, "::file-selector-button").height));
expect(fileButtonHeight).toBeGreaterThanOrEqual(44);
await importDialog.locator("textarea").fill(yamlContent);
await importDialog.getByRole("button", { name: "Import", exact: true }).tap();
const selection = testPage.getByTestId("workflow-import-profile-selection");
await expect(selection).toBeVisible();
await assertNoDocumentHorizontalOverflow(testPage, "workflow import profile selection");
const selectProfile = selection.getByTestId("workflow-import-profile-select-0:1");
const submit = selection.getByTestId("workflow-import-profile-submit");
const [selectBox, submitBox] = await Promise.all([
selectProfile.boundingBox(),
submit.boundingBox(),
]);
expect(selectBox).not.toBeNull();
expect(submitBox).not.toBeNull();
expect(selectBox!.height).toBeGreaterThanOrEqual(44);
expect(submitBox!.height).toBeGreaterThanOrEqual(44);
await selectProfile.tap();
await expect(selection.getByRole("button", { name: "Back", exact: true })).toBeVisible();
await testPage.keyboard.press("Escape");
await expect(selection.getByRole("button", { name: "Back", exact: true })).not.toBeVisible();
await expect(selectProfile).toBeVisible();
await testPage.setViewportSize({ width: 767, height: 851 });
const boundaryBox = await selectProfile.boundingBox();
expect(boundaryBox).not.toBeNull();
expect(boundaryBox!.height).toBeGreaterThanOrEqual(44);
await testPage.setViewportSize({ width: 393, height: 851 });
await selectProfile.tap();
await expect(selection.getByRole("button", { name: "Back", exact: true })).toBeVisible();
await selection.getByTestId(`workflow-import-profile-option-${replacement.id}`).tap();
await expect(selection.getByRole("button", { name: "Back", exact: true })).not.toBeVisible();
await submit.tap();
await expect(selection).not.toBeVisible();
let importedId: string | undefined;
await expect
.poll(
async () => {
const { workflows } = await apiClient.listWorkflows(seedData.workspaceId);
importedId = workflows.find((workflow) => workflow.name === workflowName)?.id;
return importedId;
},
{ timeout: 10_000 },
)
.toBeDefined();
const { steps } = await apiClient.listWorkflowSteps(importedId!);
const implement = steps.find((step) => step.name === "Implement");
const review = steps.find((step) => step.name === "Review");
expect(implement?.agent_profile_id).toBe(replacement.id);
expect(review?.session_target).toEqual({ kind: "step", step_id: implement?.id });
} finally {
const { workflows } = await apiClient.listWorkflows(seedData.workspaceId);
const imported = workflows.find((workflow) => workflow.name === workflowName);
if (imported) await apiClient.deleteWorkflow(imported.id).catch(() => {});
}
});
});
@@ -144,6 +144,128 @@ workflows:
await expect(testPage.getByText("Skipped", { exact: false })).toBeVisible({ timeout: 5000 });
});
test("import resolves a missing step profile and preserves a later session target", async ({
testPage,
apiClient,
seedData,
}) => {
const replacement = await apiClient.getAgentProfile(seedData.agentProfileId);
const workflowName = `Profile import ${Date.now()}`;
const yamlContent = `version: 2
type: kandev_workflow
workflows:
- name: ${workflowName}
steps:
- name: Start
position: 0
color: bg-neutral-400
events: {}
is_start_step: true
show_in_command_panel: true
allow_manual_move: true
complete_task_on_enter: false
auto_advance_requires_signal: false
cancel_triggers_turn_complete: false
- name: Implement
position: 1
color: bg-blue-500
events: {}
is_start_step: false
show_in_command_panel: true
allow_manual_move: true
agent_profile:
agent_name: Missing import agent
model: missing-import-model
mode: missing-import-mode
complete_task_on_enter: false
auto_advance_requires_signal: false
cancel_triggers_turn_complete: false
- name: Review
position: 2
color: bg-yellow-500
events: {}
is_start_step: false
show_in_command_panel: true
allow_manual_move: true
session_target:
kind: step
step_position: 1
complete_task_on_enter: false
auto_advance_requires_signal: false
cancel_triggers_turn_complete: false`;
try {
const page = new WorkflowSettingsPage(testPage);
await page.goto(seedData.workspaceId);
await testPage.getByRole("button", { name: "Import", exact: true }).click();
const dialog = testPage.getByRole("dialog");
const inputGeometry = await dialog.evaluate((surface) => {
const upload = surface.querySelector('input[type="file"]')!;
return {
width: surface.getBoundingClientRect().width,
fileButtonHeight: parseFloat(getComputedStyle(upload, "::file-selector-button").height),
};
});
expect(inputGeometry.width).toBeGreaterThanOrEqual(720);
expect(inputGeometry.fileButtonHeight).toBeCloseTo(28, 0);
await dialog.locator("textarea").fill(yamlContent);
await dialog.getByRole("button", { name: "Import", exact: true }).click();
const selection = testPage.getByTestId("workflow-import-profile-selection");
await expect(selection).toBeVisible();
await expect(selection.getByText("Implement", { exact: true })).toBeVisible();
await expect(testPage.getByPlaceholder("Search profiles")).not.toBeVisible();
await selection.getByTestId("workflow-import-profile-select-0:1").click();
await testPage.getByTestId(`workflow-import-profile-option-${replacement.id}`).click();
const selectedTrigger = selection.getByTestId("workflow-import-profile-select-0:1");
await expect(selectedTrigger).toContainText(replacement.name);
const geometry = await selectedTrigger.evaluate((button) => {
const label = button.querySelector("span");
const bounds = button.getBoundingClientRect();
const text = label?.getBoundingClientRect();
return {
height: bounds.height,
top: bounds.top,
bottom: bounds.bottom,
textTop: text?.top ?? 0,
textBottom: text?.bottom ?? 0,
};
});
expect(geometry.height).toBeCloseTo(28, 0);
expect(geometry.textTop).toBeGreaterThanOrEqual(geometry.top);
expect(geometry.textBottom).toBeLessThanOrEqual(geometry.bottom);
await selection.getByTestId("workflow-import-profile-submit").click();
await expect(selection).not.toBeVisible();
let importedId: string | undefined;
await expect
.poll(
async () => {
const { workflows } = await apiClient.listWorkflows(seedData.workspaceId);
importedId = workflows.find((workflow) => workflow.name === workflowName)?.id;
return importedId;
},
{ timeout: 10_000 },
)
.toBeDefined();
const { steps } = await apiClient.listWorkflowSteps(importedId!);
const implement = steps.find((step) => step.name === "Implement");
const review = steps.find((step) => step.name === "Review");
expect(implement?.agent_profile_id).toBe(replacement.id);
expect(review?.session_target).toEqual({ kind: "step", step_id: implement?.id });
await page.goto(seedData.workspaceId);
const importedCard = await page.findWorkflowCard(workflowName);
await expect(importedCard).toBeVisible();
} finally {
const { workflows } = await apiClient.listWorkflows(seedData.workspaceId);
const imported = workflows.find((workflow) => workflow.name === workflowName);
if (imported) await apiClient.deleteWorkflow(imported.id).catch(() => {});
}
});
test("round-trip: export workflow, delete, re-import preserves structure", async ({
testPage,
apiClient,
+51
View File
@@ -1062,6 +1062,57 @@ export type StepPortable = {
export type ImportWorkflowsResult = { created: string[]; skipped: string[] };
export type WorkflowImportProfileCandidate = {
id: string;
name: string;
agent_name: string;
model: string;
mode: string;
updated_at: string;
};
export type WorkflowImportProfileMatch = {
id: string;
updated_at: string;
};
export type WorkflowImportProfileStep = {
workflow_index: number;
workflow_name: string;
step_position: number;
step_name: string;
requested_profile: AgentProfilePortable;
matched_profile?: WorkflowImportProfileMatch;
};
export type WorkflowImportPreview = {
skipped: string[];
profiles: WorkflowImportProfileCandidate[];
steps: WorkflowImportProfileStep[];
};
export type WorkflowImportProfileBinding = {
workflow_index: number;
step_position: number;
requested_profile: AgentProfilePortable;
profile_id: string;
profile_updated_at: string;
};
export type WorkflowImportProfileConflict = {
workflow_index: number;
step_position: number;
workflow_name: string;
step_name: string;
reason: "missing_selection" | "unavailable_profile" | "changed_profile" | string;
};
export type WorkflowImportProfilesRequiredResponse = {
code: "workflow_import_profiles_required";
error: string;
steps: WorkflowImportProfileConflict[];
};
// Helper function to check if a step has a specific on_enter action
export function stepHasOnEnterAction(
step: { events?: StepEvents },
+17
View File
@@ -101,6 +101,23 @@
"importing": "Importing...",
"importSkipped": "Skipped (already exist): {{names}}",
"importWorkflowsTitle": "Import Workflows",
"importContinue": "Import workflows",
"importNoEligibleProfiles": "No eligible profiles are available. Enable or create a global agent profile, then retry.",
"importProfileChanged": "This profile changed. Select it again to confirm the current revision.",
"importProfileMatched": "Matched",
"importProfileNoMatches": "No matching profiles",
"importProfileNotSet": "Not set",
"importProfilePickerDescription": "Choose the profile for {{stepName}}.",
"importProfilePickerTitle": "Select a profile",
"importProfileSearchPlaceholder": "Search profiles",
"importProfileSelectionRequired": "Select a profile for this step.",
"importProfileUnavailable": "This profile is no longer available. Select another profile.",
"importRequestedProfile": "Requested profile",
"importResolveProfilesDescription": "Select a destination profile for each affected step before workflows are created.",
"importResolveProfilesTitle": "Choose profiles for imported steps",
"importSelectProfile": "Select profile",
"openAgentSettings": "Open agent profile settings",
"retryImportPreview": "Retry preview",
"invalidYaml": "Invalid YAML",
"keepCurrent": "Keep current",
"lastAttempt": "last attempt {{when}}",
@@ -101,6 +101,23 @@
"importing": "Ĩḿƥōŕţĩńĝ...",
"importSkipped": "Śķĩƥƥēď (àĺŕēàďŷ ēxĩśţ): {{names}}",
"importWorkflowsTitle": "Ĩḿƥōŕţ Ŵōŕķƒĺōŵś",
"importContinue": "Ĩḿƥōŕţ ŵōŕķƒĺōŵś",
"importNoEligibleProfiles": "Ńō ēĺĩĝĩвĺē ƥŕōƒĩĺēś àŕē àvàĩĺàвĺē. Ēńàвĺē ōŕ ćŕēàţē à ĝĺōвàĺ àĝēńţ ƥŕōƒĩĺē, ţĥēń ŕēţŕŷ.",
"importProfileChanged": "Ţĥĩś ƥŕōƒĩĺē ćĥàńĝēď. Ŝēĺēćţ ĩţ àĝàĩń ţō ćōńƒĩŕḿ ţĥē ćũŕŕēńţ ŕēvĩśĩōń.",
"importProfileMatched": "Ḿàţćĥēď",
"importProfileNoMatches": "Ńō ḿàţćĥĩńĝ ƥŕōƒĩĺēś",
"importProfileNotSet": "Ńōţ śēţ",
"importProfilePickerDescription": "Ćĥōōśē ţĥē ƥŕōƒĩĺē ƒōŕ {{stepName}}.",
"importProfilePickerTitle": "Ŝēĺēćţ à ƥŕōƒĩĺē",
"importProfileSearchPlaceholder": "Ŝēàŕćĥ ƥŕōƒĩĺēś",
"importProfileSelectionRequired": "Ŝēĺēćţ à ƥŕōƒĩĺē ƒōŕ ţĥĩś śţēƥ.",
"importProfileUnavailable": "Ţĥĩś ƥŕōƒĩĺē ĩś ńō ĺōńĝēŕ àvàĩĺàвĺē. Ŝēĺēćţ àńōţĥēŕ ƥŕōƒĩĺē.",
"importRequestedProfile": "Řēƣũēśţēď ƥŕōƒĩĺē",
"importResolveProfilesDescription": "Ŝēĺēćţ à ďēśţĩńàţĩōń ƥŕōƒĩĺē ƒōŕ ēàćĥ àƒƒēćţēď śţēƥ вēƒōŕē ŵōŕķƒĺōŵś àŕē ćŕēàţēď.",
"importResolveProfilesTitle": "Ćĥōōśē ƥŕōƒĩĺēś ƒōŕ ĩḿƥōŕţēď śţēƥś",
"importSelectProfile": "Ŝēĺēćţ ƥŕōƒĩĺē",
"openAgentSettings": "Ōƥēń àĝēńţ ƥŕōƒĩĺē śēţţĩńĝś",
"retryImportPreview": "Řēţŕŷ ƥŕēvĩēŵ",
"invalidYaml": "Ĩńvàĺĩď ŶÀḾĹ",
"keepCurrent": "Ķēēƥ ćũŕŕēńţ",
"lastAttempt": "ĺàśţ àţţēḿƥţ {{when}}",
+17
View File
@@ -97,6 +97,23 @@
"importing": "A importar...",
"importSkipped": "Ignorados (já existem): {{names}}",
"importWorkflowsTitle": "Importar fluxos de trabalho",
"importContinue": "Importar fluxos de trabalho",
"importNoEligibleProfiles": "Não existem perfis elegíveis. Ative ou crie um perfil de agente global e tente novamente.",
"importProfileChanged": "Este perfil foi alterado. Selecione-o novamente para confirmar a revisão atual.",
"importProfileMatched": "Correspondência encontrada",
"importProfileNoMatches": "Não foram encontrados perfis correspondentes",
"importProfileNotSet": "Não definido",
"importProfilePickerDescription": "Escolha o perfil para {{stepName}}.",
"importProfilePickerTitle": "Selecionar um perfil",
"importProfileSearchPlaceholder": "Pesquisar perfis",
"importProfileSelectionRequired": "Selecione um perfil para este passo.",
"importProfileUnavailable": "Este perfil já não está disponível. Selecione outro perfil.",
"importRequestedProfile": "Perfil solicitado",
"importResolveProfilesDescription": "Selecione um perfil de destino para cada passo afetado antes de criar os fluxos de trabalho.",
"importResolveProfilesTitle": "Escolher perfis para os passos importados",
"importSelectProfile": "Selecionar perfil",
"openAgentSettings": "Abrir definições dos perfis de agentes",
"retryImportPreview": "Tentar a pré-visualização novamente",
"invalidYaml": "YAML inválido",
"keepCurrent": "Manter o atual",
"lastAttempt": "última tentativa {{when}}",
+17
View File
@@ -101,6 +101,23 @@
"importing": "正在导入…",
"importSkipped": "已跳过(已存在):{{names}}",
"importWorkflowsTitle": "导入工作流",
"importContinue": "导入工作流",
"importNoEligibleProfiles": "没有可用的合格代理配置。请启用或创建全局代理配置,然后重试。",
"importProfileChanged": "此代理配置已更改。请再次选择它以确认当前版本。",
"importProfileMatched": "已匹配",
"importProfileNoMatches": "没有匹配的代理配置",
"importProfileNotSet": "未设置",
"importProfilePickerDescription": "为 {{stepName}} 选择代理配置。",
"importProfilePickerTitle": "选择代理配置",
"importProfileSearchPlaceholder": "搜索代理配置",
"importProfileSelectionRequired": "请为此步骤选择代理配置。",
"importProfileUnavailable": "此代理配置已不可用。请选择其他代理配置。",
"importRequestedProfile": "请求的代理配置",
"importResolveProfilesDescription": "在创建工作流前,请为每个受影响的步骤选择目标代理配置。",
"importResolveProfilesTitle": "为导入的步骤选择代理配置",
"importSelectProfile": "选择代理配置",
"openAgentSettings": "打开代理配置设置",
"retryImportPreview": "重试预览",
"invalidYaml": "无效的 YAML",
"keepCurrent": "保留当前设置",
"lastAttempt": "上次尝试 {{when}}",
+17
View File
@@ -101,6 +101,23 @@
"importing": "正在匯入…",
"importSkipped": "已跳過(已存在):{{names}}",
"importWorkflowsTitle": "匯入工作流程",
"importContinue": "匯入工作流程",
"importNoEligibleProfiles": "沒有可用的合資格代理程式設定。請啟用或建立全域代理程式設定,然後重試。",
"importProfileChanged": "此代理程式設定已變更。請再次選取它以確認目前版本。",
"importProfileMatched": "已符合",
"importProfileNoMatches": "沒有相符的代理程式設定",
"importProfileNotSet": "未設定",
"importProfilePickerDescription": "為 {{stepName}} 選取代理程式設定。",
"importProfilePickerTitle": "選取代理程式設定",
"importProfileSearchPlaceholder": "搜尋代理程式設定",
"importProfileSelectionRequired": "請為此步驟選取代理程式設定。",
"importProfileUnavailable": "此代理程式設定已無法使用。請選取其他代理程式設定。",
"importRequestedProfile": "要求的代理程式設定",
"importResolveProfilesDescription": "建立工作流程前,請為每個受影響的步驟選取目標代理程式設定。",
"importResolveProfilesTitle": "為匯入的步驟選取代理程式設定",
"importSelectProfile": "選取代理程式設定",
"openAgentSettings": "開啟代理程式設定",
"retryImportPreview": "重試預覽",
"invalidYaml": "無效的 YAML",
"keepCurrent": "保留目前設定",
"lastAttempt": "上次嘗試 {{when}}",
+17
View File
@@ -101,6 +101,23 @@
"importing": "正在匯入…",
"importSkipped": "已跳過(已存在):{{names}}",
"importWorkflowsTitle": "匯入工作流程",
"importContinue": "匯入工作流程",
"importNoEligibleProfiles": "沒有可用的合資格代理程式設定。請啟用或建立全域代理程式設定,然後重試。",
"importProfileChanged": "此代理程式設定已變更。請再次選取它以確認目前版本。",
"importProfileMatched": "已符合",
"importProfileNoMatches": "沒有相符的代理程式設定",
"importProfileNotSet": "未設定",
"importProfilePickerDescription": "為 {{stepName}} 選取代理程式設定。",
"importProfilePickerTitle": "選取代理程式設定",
"importProfileSearchPlaceholder": "搜尋代理程式設定",
"importProfileSelectionRequired": "請為此步驟選取代理程式設定。",
"importProfileUnavailable": "此代理程式設定已無法使用。請選取其他代理程式設定。",
"importRequestedProfile": "要求的代理程式設定",
"importResolveProfilesDescription": "建立工作流程前,請為每個受影響的步驟選取目標代理程式設定。",
"importResolveProfilesTitle": "為匯入的步驟選取代理程式設定",
"importSelectProfile": "選取代理程式設定",
"openAgentSettings": "開啟代理程式設定",
"retryImportPreview": "重試預覽",
"invalidYaml": "無效的 YAML",
"keepCurrent": "保留目前設定",
"lastAttempt": "上次嘗試 {{when}}",
@@ -0,0 +1,181 @@
---
created: 2026-09-17
status: complete
requirements:
- REQ-TASKS-IMPORT-PROFILES-001
system_design:
- ../../specs/tasks/system-design/workflow-import-profile-selection.md
legacy_specs: []
---
# Implementation plan: Workflow import profile selection
## Overview
Add a profile selection stage to manual workflow import.
Deliver one vertical work order covering the API, desktop/phone interaction, and persistence evidence.
Start with failing service and client tests, implement the selection flow, then run focused browser checks.
## Scope
### In scope
- Preview missing direct step profiles without persistence.
- Select existing replacements independently and preserve exact profile IDs.
- Preserve session targets and existing name deduplication.
- Validate the complete selection batch before writes.
- Provide desktop and phone selection, recovery, localization, and public documentation.
### Out of scope
- Workflow-default or review-action profile remapping.
- Unattended sync changes, inline profile creation, and runtime model selection changes.
- Changing legacy raw-YAML or MCP import behavior.
- New database transactions, migrations, or agent sessions.
## Technical approach
Add a read-only preview route beside `httpImportWorkflows`.
Extend that import handler with a JSON envelope for exact bindings while preserving its YAML path.
Add a context-aware profile catalog through backend service wiring.
The workflow service prepares and validates the selected batch before persistence.
It must preserve `sync_apply.go` behavior when extracting helpers from `service.go`.
Extract import state from `useWorkflowImportExport` into `use-workflow-import.ts` beside the current client.
Extend `ImportWorkflowsDialog` through focused components rather than growing the settings client.
Use the existing profile row style, with a Dialog on desktop and one navigable Drawer on phones.
The [system design](../../specs/tasks/system-design/workflow-import-profile-selection.md) defines contracts and recovery rules.
## ASCII UI preview
Entry point: Settings > Workspace > Workflows > Import.
Current behavior: the reported YAML produces a toast and closes no successful import.
Proposed behavior: the same input opens UI-01 with one unresolved step.
### UI-01: Desktop, unresolved profile
```text
+------------------------------------------------------------+
| Choose agent profiles X |
| Some step profiles are not available on this installation. |
|------------------------------------------------------------|
| Feature |
| Implement |
| Requested: Codex / gpt-5.6-luna / agent-full-access |
| Agent profile: [Choose a profile v] |
| |
| Other missing steps appear here, grouped by workflow. |
|------------------------------------------------------------|
| [Back] [Cancel] [Import (disabled)] |
+------------------------------------------------------------+
```
### UI-02: Phone, unresolved profile
```text
+----------------------------------+
| < Back Choose profiles X |
|----------------------------------|
| Feature |
| Implement |
| Requested agent: Codex |
| Model: gpt-5.6-luna |
| Mode: agent-full-access |
| [Choose a profile >] |
| |
| Other missing steps |
| (one scrolling body) |
|----------------------------------|
| [Import (disabled)] |
| safe-area space |
+----------------------------------+
```
### UI-03: Phone, profile picker subview
```text
+----------------------------------+
| < Back Implement |
| [Search profiles ] |
|----------------------------------|
| (logo) Implementation profile |
| Codex / available model / mode |
| |
| (logo) Another profile |
| Another agent / model / mode |
| (one scrolling candidate list) |
+----------------------------------+
```
UI-01 and UI-02 keep header/footer fixed. UI-03 replaces the body within the same phone surface.
Selecting a profile returns to UI-02. The Import button becomes enabled after every required selection.
The initial YAML dialog and UI-01 share a 48 rem desktop maximum width.
The file chooser is a compact secondary action. The YAML editor starts at 14 rem high.
Every picker starts closed. A selected trigger shows one line without vertical clipping.
An empty list shows “No available profiles”, a profile-settings link, and Retry.
A stale profile shows an inline error on its step. YAML and valid selections remain available.
Loading has a status region. Final submission disables repeated actions and dismissal.
Control order, grouping, navigation, scroll ownership, and action availability are required.
Spacing and example labels are illustrative. All UI text uses localization keys.
These views cover AC-TASKS-IMPORT-PROFILES-001.1, .3, .6, .8, and .9.
## Tests
| Criteria | Evidence |
| --- | --- |
| 001.1-.4, .7 | `import_profile_selection_test.go`: preview, exact matches, independent step replacements, and PR target preservation |
| 001.5-.7 | Same service suite: deleted/disabled/changed profiles, invalid mappings, skipped names, and zero writes on validation failure |
| 001.1, .5 | `import_profile_selection_test.go` in handlers: authorization, request limits, content types, and conflict response |
| 001.6, .9 | `use-workflow-import.test.ts`: generations, draft retention, retries, and duplicate submissions |
| 001.2, .4 | Existing service import/sync tests remain unchanged in behavior |
All identifiers have prefix `AC-TASKS-IMPORT-PROFILES-`.
## E2E tests
- Extend `e2e/tests/workflow/workflow-import-export.spec.ts` in `chromium`.
Import the supplied six-step Feature structure with an unavailable descriptor, select a replacement, reload, and inspect Implement and PR.
Cover multiple unresolved steps, exact-match bypass, cancellation, and recoverable errors.
- Add `e2e/tests/workflow/mobile-workflow-import-export.spec.ts` in `mobile-chrome`.
Import by paste, use UI-03 through touch, return to UI-02, submit, reload, and inspect saved values.
Cover long lists, internal scrolling, no horizontal overflow, safe footer placement, focus return, and 44 px targets.
- Retain existing upload, paste, invalid YAML, and duplicate-name scenarios.
## Work orders
- [x] [Task 01: Implement import profile selection](task-01-import-profile-selection.md)
## Verification results
UX correction on 2026-09-17 passed 15 focused frontend tests, 8 desktop E2E tests, and 1 mobile E2E test.
Browser assertions cover the closed picker, selected-label containment, wider dialog, and desktop/phone file-button heights.
Typecheck, targeted ESLint, specification lint, catalog validation, and diff checks also passed.
See Task 01 for the correction and build provenance.
Design validation on 2026-09-17:
- `python3 scripts/list-docs.py validate`: passed, 990 specifications and 285 decisions.
- `python3 scripts/lint-spec-files.test.py`: passed, 36 tests.
- `python3 scripts/lint-spec-files.py --all`: passed.
- `git diff --check -- docs/specs docs/plans/workflow-import-profile-selection`: passed.
- `git status --short -- docs/specs docs/plans/workflow-import-profile-selection`: all four new artifacts are present and uncommitted.
Product verification completed on 2026-09-17:
- Backend focused workflow, controller, handler, and profile-catalog tests passed.
- Focused frontend Vitest tests, typecheck, ESLint, and i18n checks passed.
- Desktop Chromium workflow import E2E passed with 8 tests.
- Mobile Chromium workflow import E2E passed with 1 test.
- Public-doc validation, specification validation and lint, and `git diff --check` passed.
Review fixup verification also covers empty-catalog HTTP serialization,
rendered desktop and phone empty/loading states, deferred cancellation and
reopen, late file-read results, and in-flight edit/repeated-submit protection.
## Risks
- Import and sync share conversion helpers. Interactive checks must remain specific to the new API path.
- Profile edits can occur after preview. Final selection validation must use profile revisions and preserve exact selected IDs.
- A lost response can follow successful persistence. A retry must use existing workflow-name deduplication.
- Workflow-default and review-action profiles retain existing behavior and can remain unmatched outside this step picker.
@@ -0,0 +1,202 @@
---
id: "01-import-profile-selection"
title: "Implement workflow import profile selection"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-TASKS-IMPORT-PROFILES-001
acceptance_criteria:
- AC-TASKS-IMPORT-PROFILES-001.1
- AC-TASKS-IMPORT-PROFILES-001.2
- AC-TASKS-IMPORT-PROFILES-001.3
- AC-TASKS-IMPORT-PROFILES-001.4
- AC-TASKS-IMPORT-PROFILES-001.5
- AC-TASKS-IMPORT-PROFILES-001.6
- AC-TASKS-IMPORT-PROFILES-001.7
- AC-TASKS-IMPORT-PROFILES-001.8
- AC-TASKS-IMPORT-PROFILES-001.9
system_design:
- ../../specs/tasks/system-design/workflow-import-profile-selection.md
---
# Task 01: Implement workflow import profile selection
## Summary
Deliver the manual import flow from preview through profile selection to persisted workflows.
Use TDD for the service, HTTP contract, and client state, then verify both browser compositions.
## In scope
- Implement the preview and explicit JSON import contracts in the linked design.
- Wire eligible profile lookup with caller context and exact-ID validation.
- Preserve all session targets and validate the selected batch before persistence.
- Implement shared import state and desktop/phone selection surfaces.
- Add translated loading, empty, conflict, retry, and success states.
- Update public import documentation with browser selection and legacy API distinctions.
## Out of scope
The exclusions in the [plan](plan.md#scope) apply.
Do not create profiles, change profile configuration, or alter sync behavior.
## Acceptance
1. The supplied Feature structure imports after explicit selection for Implement. PR still targets that step after reload.
2. Exact matches proceed automatically. Missing, stale, unauthorized, and malformed selections cannot cause partial validation writes or guessed replacements.
3. Desktop and phone users complete the flow, recover from errors, and retain selections through valid retries. Locale and focused regression checks pass.
## ASCII UI preview
These excerpts use the labels from the [full preview](plan.md#ascii-ui-preview).
They cover AC-TASKS-IMPORT-PROFILES-001.1, .3, .6, .8, and .9.
```text
UI-01 Desktop:
Choose agent profiles
Feature > Implement
Requested: Codex / gpt-5.6-luna / agent-full-access
[Choose a profile v]
[Back] [Cancel] [Import]
UI-02 Phone: UI-03 Phone picker:
< Back Choose profiles X < Back Implement
Feature > Implement [Search profiles]
Requested agent/model/mode [Profile / agent / model / mode]
[Choose a profile >] [Another profile]
(scrolling step list) (scrolling candidate list)
[Import] + safe-area padding
```
UI-03 replaces UI-02 within one drawer. Back preserves selections.
Headers and primary actions stay fixed. Each active view has one vertical scroll region.
The YAML dialog and UI-01 use the same wider desktop size, with a compact secondary file chooser.
Pickers start closed. Selected triggers use one line, while candidate rows retain their detailed labels.
Import stays disabled while a required selection is empty or submission is pending.
An empty catalog provides settings access and Retry. A profile conflict identifies the affected step inline.
Compare rendered desktop and phone views with these structural requirements during E2E verification.
## Verification
Run each command from the repository root. The managed E2E runner rebuilds the application.
Use the recorded exact suite names for the new unit tests.
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps/backend && go test ./internal/workflow/service ./internal/workflow/controller ./internal/workflow/handlers -count=1)
(cd apps/backend && go test ./internal/backendapp -run 'TestBuildAgentProfileMatcher|TestWorkflowImportProfileCatalog' -count=1)
(cd apps/web && pnpm exec vitest run app/settings/workspace/use-workflow-import.test.ts app/settings/workspace/workflow-import-profile-selection.test.tsx)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm exec eslint app/settings/workspace/workspace-workflows-client.tsx app/settings/workspace/workspace-workflows-dialogs.tsx app/settings/workspace/use-workflow-import.ts app/settings/workspace/workflow-import-profile-selection.tsx app/actions/workspaces.ts lib/types/http.ts)
(cd apps/web && pnpm run i18n:check)
(cd apps/web && pnpm e2e:run --project chromium tests/workflow/workflow-import-export.spec.ts)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/workflow/mobile-workflow-import-export.spec.ts)
node --test scripts/validate-public-docs.test.mjs
node scripts/validate-public-docs.mjs
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
git diff --check
```
The new backend wiring test must be named `TestWorkflowImportProfileCatalog` or use that prefix.
The new Vitest files and E2E file must exist before their commands run.
If implementation requires additional focused files, update the command list to cover them.
No broad QA or review gate is part of this task.
## Files likely touched
- `apps/backend/internal/workflow/service/service.go`
- New `apps/backend/internal/workflow/service/import_profile_selection.go` and corresponding test file
- `apps/backend/internal/workflow/controller/controller.go`
- `apps/backend/internal/workflow/handlers/handlers.go`
- New `apps/backend/internal/workflow/handlers/import_profile_selection.go` and corresponding test file
- `apps/backend/internal/backendapp/services.go` and `agent_profile_matcher_test.go`
- `apps/web/app/actions/workspaces.ts`
- `apps/web/lib/types/http.ts`
- `apps/web/app/settings/workspace/workspace-workflows-client.tsx`
- `apps/web/app/settings/workspace/workspace-workflows-dialogs.tsx`
- New `apps/web/app/settings/workspace/use-workflow-import.ts` and corresponding test file
- New `apps/web/app/settings/workspace/workflow-import-profile-selection.tsx` and corresponding test file
- `apps/web/src/locales/*/workflows.json`
- `apps/web/e2e/tests/workflow/workflow-import-export.spec.ts`
- New `apps/web/e2e/tests/workflow/mobile-workflow-import-export.spec.ts`
- `docs/public/workflow-import-export.md`
- This plan, work order, and paired design for final status and evidence
## Dependencies
None. Work is sequential within this vertical slice.
## Risks
The import helper is shared with sync. Preserve its current contract.
The current profile hook also applies runtime health and feature filters. It must not silently redefine server import eligibility.
Generic API errors can discard response details. Verify structured conflicts through the actual HTTP action.
Do not claim transaction atomicity for storage failures beyond the existing import contract.
## Parallelism
`sequential`
## Inputs
- [Requirements](../../specs/tasks/requirements/workflow-import-profile-selection.md)
- [System design](../../specs/tasks/system-design/workflow-import-profile-selection.md)
- `apps/backend/AGENTS.md` and `apps/web/AGENTS.md`
- Existing workflow import service tests and `e2e/tests/workflow/workflow-import-export.spec.ts`
- `components/kanban/mobile-menu-sheet.tsx` for viewport and scroll geometry
- `components/settings/workflow-session-target-surfaces.tsx` for profile rows
- `/tdd`, `/e2e`, `/mobile-parity`, and `/docs-maintainer`
## Results
UX correction on 2026-09-17: pickers now start closed, selected profile labels use one line, and both desktop dialogs use a 48 rem maximum width.
The file button uses a secondary style with a 28 px desktop height and a 44 px phone height.
The YAML editor starts taller and has bounded scrolling.
The closed-picker assertion failed before the hook change and passed afterward.
Final checks for this correction passed:
- Focused Vitest command above: 15 tests.
- Desktop E2E command above: 8 tests, including width, file-button size, normal profile click, and selected-label containment.
- Mobile E2E command above: 1 test, including the file-button touch target.
- Typecheck, targeted ESLint, specification lint, catalog validation, and `git diff --check`.
The first desktop E2E run rebuilt the application. Final desktop and mobile runs reused that build with `--no-build` after test-only additions.
Implemented the browser workflow import profile-resolution flow end to end.
The backend now previews eligible global profiles, validates explicit profile
bindings and revisions before persistence, and preserves direct profile IDs and
session targets. The web client now provides localized desktop and phone
selection surfaces, retry and conflict recovery, and generation-safe import
state. Public import documentation describes the preview, browser envelope,
profile conflicts, and raw YAML behavior.
Review fixups preserve a non-null empty profile catalog, invalidate canceled
previews and late file reads, keep final submission ownership through draft
edits, and keep the selection surface mounted during pending submission.
Storage failures compensate by deleting already-created steps and the new
workflow. Focused regressions cover empty-catalog HTTP serialization,
malformed binding validation, partial-step rollback, rendered desktop and
phone empty/loading states, deferred cancellation and reopen, late file-read
results, stable busy status, in-flight edit/repeated-submit protection, coarse
pointer tablet drawers, picker Escape navigation, and the 767 px touch-target
boundary.
Verification passed:
- Backend focused workflow, controller, handler, and profile-catalog tests,
including the empty eligible-catalog HTTP array regression.
- Focused frontend Vitest suite: 15 tests passed, including rendered desktop
and phone empty/loading states, the deferred final-submit dialog path,
deferred cancellation and reopen, late file-read results, stable busy
status, and in-flight edit/repeated-submit protection.
- Focused backend import suite passed, including malformed binding rejection,
workflow-field rollback, and partial-step rollback.
- Frontend typecheck, ESLint, i18n checks, and E2E production build.
- Desktop Chromium workflow import E2E: 8 tests passed.
- Mobile Chromium workflow import E2E: 1 test passed, including picker Escape
navigation, coarse-pointer drawer behavior, and the 767 px control boundary.
- Public-doc validation, specification validation and lint, and `git diff --check`.
+66 -7
View File
@@ -22,7 +22,9 @@ Open **Settings → Workspaces → select a workspace → Workflows**.
- **Export All** opens a YAML dialog for the saved Kanban workflows visible on that settings page. Unsaved drafts and Office-style workflows are excluded. Choose **Copy** to put the text on the clipboard.
- A workflow card's **Export** button exports only that workflow.
- **Import** accepts a `.yml` or `.yaml` file, or pasted YAML. The result reports created and skipped workflow names.
- **Import** accepts a `.yml` or `.yaml` file, or pasted YAML. It previews the workflows that will be created and skipped. Exact direct step-profile matches are selected automatically; each unmatched direct step has a profile picker.
- The browser import does not create workflows until every affected step has a valid profile selection. If no eligible profile exists, use **Retry** after creating or enabling a profile in **Settings > Agents**. Canceling the picker leaves the workspace unchanged.
- The result reports created and skipped workflow names.
Export does not download a file or change the workflow. Import creates new workflows; it never overwrites a same-named workflow. Delete an unwanted imported workflow through the normal workflow settings flow.
@@ -40,11 +42,12 @@ All routes are on the Kandev backend under `/api/v1`.
| `GET` | `/workflows/:id/export` | Export one workflow as `application/x-yaml`. |
| `GET` | `/workspaces/:id/workflows/export` | Export all non-hidden workflows in the workspace. |
| `GET` | `/workspaces/:id/workflows/export?ids=id1,id2` | Export only the listed workflow IDs. Whitespace and empty comma elements are ignored. |
| `POST` | `/workspaces/:id/workflows/import/preview` | Validate a portable YAML request and return skipped names, eligible global profiles, and direct step-profile matches. It does not write. |
| `POST` | `/workspaces/:id/workflows/import` | Parse a portable YAML request and return `{"created": [...], "skipped": [...]}`. |
The workspace export route treats an absent `ids` parameter as “all”; `ids=` is an explicit empty selection and returns an envelope with no workflows. Such an envelope cannot be imported because validation requires at least one workflow. The UI supplies IDs for its Kanban-only selection. A direct “all” HTTP export can include workflow styles the portable converter cannot completely represent, so prefer the UI for user-managed Kanban workflows.
The import handler reads at most 1 MiB. It uses a limited reader rather than a dedicated `413` check, so an oversized document is truncated and normally fails YAML parsing or validation. The HTTP route always uses the YAML decoder. The same structs have JSON tags because GitHub workflow sync accepts `.json` files, but YAML is the import endpoint's documented request format.
Import requests are limited to 1 MiB. A larger request returns `413 Request Entity Too Large` and does not write. The preview route accepts portable YAML. The browser's final import request uses `application/json` with the YAML text and explicit direct step-profile bindings; a raw `application/x-yaml` request remains available for scripts and unattended callers.
Example with `curl`:
@@ -57,6 +60,35 @@ curl -fsS \
-H 'Content-Type: application/x-yaml' \
--data-binary @workflow.yml \
"http://localhost:38429/api/v1/workspaces/WORKSPACE_ID/workflows/import"
# Browser-style import with explicit profile bindings:
curl -fsS \
-H 'Content-Type: application/json' \
--data-binary @import-request.json \
"http://localhost:38429/api/v1/workspaces/WORKSPACE_ID/workflows/import"
```
An explicit import request has this shape. `requested_profile` must repeat the
portable descriptor for the referenced step. `profile_updated_at` is the
candidate revision returned by the preview route.
```json
{
"yaml": "version: 2\ntype: kandev_workflow\nworkflows: [...]\n",
"step_profile_bindings": [
{
"workflow_index": 0,
"step_position": 1,
"requested_profile": {
"agent_name": "Claude Code",
"model": "optional-model-id",
"mode": "optional-mode-id"
},
"profile_id": "DESTINATION_PROFILE_ID",
"profile_updated_at": "2026-01-01T00:00:00Z"
}
]
}
```
If Kandev is behind a reverse proxy, use its externally protected base URL rather than the loopback example.
@@ -219,20 +251,47 @@ agent_profile:
mode: optional-mode-id
```
`agent_name` is the agent display name, not an internal ID. On import Kandev searches for a profile whose display name, model, and mode all match exactly. Empty optional values also participate in the match.
`agent_name` is the agent display name, not an internal ID. Kandev searches for a profile whose display name, model, and mode all match exactly. Empty optional values also participate in the match.
In the browser import flow, the preview lists enabled global profiles from the
destination workspace. An exact direct step-profile match is selected
automatically. Each unmatched direct step must be assigned an eligible profile
before the import can continue. The final request includes the selected profile
ID and its preview revision, so Kandev detects a profile that was removed,
disabled, or changed while the picker was open. A conflict returns `409` with
`code: workflow_import_profiles_required`; choose a replacement and submit
again. Use Retry to refresh candidate revisions after a stale-profile conflict;
unchanged selections remain available. Profile selection and the complete
validation pass happen before the browser flow creates any workflow.
Workflow-level profile descriptors are still matched automatically. A missing
workflow-level match remains unset. Raw YAML imports and GitHub workflow sync
keep their existing exact-match behavior, including leaving an unmatched direct
step profile unset. They do not use the browser picker.
If there is no exact match, import still succeeds and silently leaves that workflow or step profile unset. Before moving a file between installs, compare the destination's agent display names and supported model/mode identifiers. Never assume an illustrative model name exists in another install.
## Import reconciliation and failure behavior
Import follows these rules:
Browser imports follow these rules:
1. The complete envelope is decoded and validated before creation begins.
1. The preview decodes and validates the complete envelope, applies name deduplication, and returns direct step-profile candidates without creating anything.
2. Existing workflows are compared by exact name. Matches are reported under `skipped`; they are not updated or merged.
3. Each new workflow and its steps receive fresh IDs. Step-position references are remapped to those IDs.
4. Profile descriptors are matched by value.
4. Exact profile matches are preselected. Unmatched direct step descriptors require explicit eligible profile selections.
5. The final request validates every binding, profile ID, profile revision, and portable reference before creating any workflow.
Validation failure writes nothing. Creation itself is not one transaction across the file, however. A database or profile-update failure after creation begins can leave earlier workflows, a workflow without all steps, or other partial state. Inspect the workspace after a runtime error and delete incomplete workflows before retrying.
Browser preview, selection validation, and profile conflicts write nothing. After
that validation pass, a database failure during creation is still not one
transaction across the file. Inspect the workspace after a runtime error and
delete incomplete workflows before retrying.
The raw YAML endpoint retains its existing behavior. It validates before
creation, but creation is not one transaction across the file. A database or
profile-update failure after creation begins can leave earlier workflows, a
workflow without all steps, or other partial state. Raw YAML callers should
inspect the workspace after a runtime error and delete incomplete workflows
before retrying.
The validator currently does **not** require a step, contiguous or non-negative positions, unique step names, unique workflow names within the same document, a valid color, or exactly one start step. GitHub workflow sync adds a unique-step-name requirement because it reconciles by name. For predictable results, enforce all of those constraints in authored files even when the one-time importer accepts them.
@@ -0,0 +1,70 @@
---
status: draft
system: tasks
created: 2026-09-17
owners:
- kandev
---
# Workflow import profile selection
## Overview
Users can import workflows whose step profiles do not exist on the destination installation.
They choose an existing replacement profile for each affected step before import.
The task system owns this behavior because it owns workflow definitions and their session targets.
The agent system continues to own profile configuration and eligibility.
## Terminology
- **Missing profile:** An explicit step profile descriptor without an eligible exact local match.
- **Replacement:** An existing local profile that the user selects for one missing profile.
- **Eligible profile:** An enabled global profile available to the importing user under existing profile access rules.
## Requirements
### REQ-TASKS-IMPORT-PROFILES-001: Resolve missing step profiles before import
**Intent:** Make imported steps usable without requiring manual YAML edits.
#### Acceptance criteria
- **AC-TASKS-IMPORT-PROFILES-001.1:** When a manual browser import has missing profiles, Kandev shall show every affected workflow and step before creation.
Each entry shall show the requested agent, model, and mode and a searchable profile picker.
- **AC-TASKS-IMPORT-PROFILES-001.2:** When a step has an eligible exact match, Kandev shall retain that match without requiring user selection.
When all explicit step profiles match, import shall proceed without an additional selection screen.
- **AC-TASKS-IMPORT-PROFILES-001.3:** Each missing profile shall require an explicit selection before import becomes available.
Users can select different replacements for steps with identical requested profiles.
The picker shall offer eligible profiles across agent families and identify each profile by name, agent, model, and mode.
- **AC-TASKS-IMPORT-PROFILES-001.4:** Kandev shall preserve initial-session targets, earlier-step targets, prompts, events, and session lifecycle settings after profile selection.
A step without an explicit profile descriptor shall not require a replacement.
Selecting a replacement for Implement shall preserve PR's reference to Implement.
- **AC-TASKS-IMPORT-PROFILES-001.5:** When any replacement is unresolved or invalid, Kandev shall create no workflows from that selection submission.
Disabled, removed, inaccessible, or workspace-scoped profiles shall not become replacement bindings.
A profile change after selection shall require another selection or review before import.
- **AC-TASKS-IMPORT-PROFILES-001.6:** When no eligible profiles exist, Kandev shall explain the problem and provide access to profile settings and a retry action.
Cancellation shall create nothing. Recoverable errors shall preserve the YAML and valid selections.
A YAML or destination change shall invalidate the previous selection review.
- **AC-TASKS-IMPORT-PROFILES-001.7:** Workflows skipped under existing name deduplication shall not require profile selections.
Kandev shall identify repeated step names by their workflow and position.
- **AC-TASKS-IMPORT-PROFILES-001.8:** Desktop and phone users shall complete the same selection and import flow.
The phone surface shall provide internal scrolling, visible navigation, safe-area spacing, and touch targets of at least 44 px.
Neither surface shall cause document horizontal overflow. Keyboard users shall select profiles and return to the initiating control after dismissal.
- **AC-TASKS-IMPORT-PROFILES-001.9:** Loading and retry states shall have localized status text.
Duplicate submissions shall remain unavailable while an import request is pending.
After success and reload, each imported step shall retain its selected profile and session targets.
## Compatibility and exclusions
This change covers explicit step profiles in manual browser imports of supported workflow exports.
The existing raw-YAML API and MCP import contracts retain their behavior.
The browser uses an explicit selection submission contract.
Workflow-default profiles, review-action profiles, conditional session rules, and unattended workflow sync retain their existing behavior.
Inline profile creation, profile configuration changes, runtime model fallback, and automatic replacement selection are outside this capability.
The capability does not create agent sessions or change existing workflows.
## Related contracts
- [Workflow session recipients](workflow-profile-session-lifecycle.md)
- [Profile disable behavior](../../agents/requirements/profile-disable.md)
- [System design](../system-design/workflow-import-profile-selection.md)
@@ -0,0 +1,184 @@
---
status: draft
system: tasks
requirements:
- REQ-TASKS-IMPORT-PROFILES-001
---
# Workflow import profile selection design
## Purpose and boundaries
The manual import flow resolves explicit step profiles before workflow persistence.
It preserves the direct-profile invariant for earlier-step session targets.
The existing matcher remains authoritative for automatic exact matches.
Selections use profile IDs, so duplicate descriptors cannot redirect a user's choice to another local profile.
No database migration or portable YAML version change is required.
The implementation does not change unattended sync or the legacy raw-YAML import path.
This is a local import extension. Its requirement and design preserve the rationale without a separate ADR.
## Requirement mapping
| Acceptance criteria | Design sections |
| --- | --- |
| 001.1, 001.2, 001.3, 001.7 | Preview and selection contracts |
| 001.4, 001.5 | Validation and persistence |
| 001.6, 001.9 | Client flow and recovery |
| 001.8 | Responsive composition |
All identifiers in this table have prefix `AC-TASKS-IMPORT-PROFILES-`.
## Existing components
- `apps/backend/internal/workflow/service/service.go`: `ImportWorkflows`, `importSingleWorkflow`, `stepFromPortableWithMatcher`, and `validateWorkflowSessionTargets`.
- `apps/backend/internal/backendapp/services.go`: `buildAgentProfileMatcher` and `selectAgentProfileCandidate`.
- `apps/backend/internal/workflow/handlers/handlers.go`: `httpImportWorkflows` and workspace routes.
- `apps/backend/internal/workflow/controller/controller.go`: `ImportWorkflowsRequest` and controller delegation.
- `apps/web/app/settings/workspace/workspace-workflows-client.tsx`: `useWorkflowImportExport` currently sends YAML directly.
- `apps/web/app/settings/workspace/workspace-workflows-dialogs.tsx`: `ImportWorkflowsDialog` currently offers upload and paste.
- `apps/web/app/actions/workspaces.ts` and `apps/web/lib/types/http.ts`: HTTP actions and response types.
New import resolution code belongs in focused service, handler, hook, and component files beside these boundaries.
The backend wiring supplies a context-aware profile catalog to the workflow service.
The catalog lists eligible profiles and retrieves selected profiles using existing agent settings repositories and access rules.
Catalog failures propagate as retryable errors instead of appearing as an empty candidate list.
## Preview and selection contracts
Add `POST /api/v1/workspaces/:id/workflows/import/preview` with the existing raw-YAML request format and size limit.
This endpoint performs no persistence. It authorizes the destination before exposing any profile or workflow information.
It parses, normalizes, and validates the portable export with the existing model validation.
It applies current workflow-name deduplication before resolving direct step profiles.
The JSON response contains:
- `skipped`: workflow names excluded by existing deduplication.
- `profiles`: eligible candidates with `id`, profile label, agent display name, model, mode, and `updated_at`.
- `steps`: explicit direct-profile steps in workflows that will be imported.
Each entry contains `workflow_index`, workflow name, `step_position`, step name, the requested descriptor, and optional matched profile identity.
A matched identity contains `id` and `updated_at`.
Workflow index refers to the original document array. Step position refers to the validated portable position.
Names are display labels, not binding keys. Repeated descriptors do not share selection state.
Missing and empty optional descriptor fields follow existing matcher semantics.
Automatic matching preserves the enabled/global filter and oldest-candidate tie-break rule.
The catalog does not redefine runtime health, model fallback, or discovery policy.
Extend the existing import route with an explicit `application/json` request form:
```json
{
"yaml": "version: 2\n...",
"step_profile_bindings": [
{
"workflow_index": 0,
"step_position": 2,
"requested_profile": {
"agent_name": "Codex",
"model": "gpt-5.6-luna",
"mode": "agent-full-access"
},
"profile_id": "selected-local-profile-id",
"profile_updated_at": "2026-09-17T13:00:00Z"
}
]
}
```
The browser submits bindings for every explicit direct-profile step, including automatic matches.
The backend distinguishes the JSON envelope by content type, not body guessing.
Existing raw YAML, including JSON-shaped portable exports submitted with the existing YAML content type, retains its current parsing path.
Both request forms retain the existing success shape: `created` and `skipped`.
Body limits apply to both forms. Oversized or truncated bodies return an explicit request error.
## Validation and persistence
The new service entry point authorizes, parses, and validates the entire submission again.
It reloads existing workflow names and selected profiles before preparing any workflow.
It rejects duplicate binding keys, unknown positions, descriptor mismatches, and bindings on steps without explicit profiles.
Bindings for workflows now skipped by name are ignored after structural validation.
Every remaining direct-profile step requires exactly one binding.
Selected IDs must remain eligible and retain their submitted `updated_at` value.
The timestamp is a consistency guard, not an authorization credential.
Unavailable profiles use a generic unavailable reason without exposing private profile data.
Changed timestamps require renewed review even when a descriptor still matches.
The final service uses the loaded profile IDs directly. It never rematches the replacement's descriptor.
Prepare all workflow steps in memory, apply exact selected IDs, then run existing step, task-reference, and session-target validation.
Only after every candidate workflow passes validation does persistence start.
The prepared steps are the steps that persistence consumes. No second matcher pass can change them.
Extract preparation/persistence helpers only where they preserve existing import and sync callers.
`sync_apply.go` calls `importSingleWorkflow`, so new interactive requirements must not leak into that shared path.
Profile resolution failures return HTTP 409 with `code: workflow_import_profiles_required` and affected step keys and reason codes.
Reasons distinguish missing selection, unavailable profile, and changed profile. Malformed bindings return HTTP 400.
The legacy import path retains its error behavior.
The client uses structured fields, never an English error-string match.
The existing `ApiError` handling must retain structured details for this action, or the action must decode its response locally.
The no-write guarantee covers preflight, selection, and validation failures across the submitted batch.
Storage failures trigger compensating deletion of steps and the newly created workflow for the current item. This change does not introduce a cross-repository transaction.
After an uncertain network result, retry rechecks name deduplication before creation.
## Client flow and recovery
Extract `useWorkflowImportExport` state for import into a focused hook with states for editing, preview, resolution, and submission.
The Import action first requests preview. With no missing step profiles, it submits the returned exact bindings automatically.
Otherwise it shows all missing steps grouped by workflow, with an independent selector for each step.
The selected profile label includes agent, model, and mode so a different agent family is an informed choice.
Matched steps remain bound automatically. Initial-session and earlier-step targets do not get replacement pickers.
Editing YAML, uploading another file, changing destination, or starting a new import invalidates previous preview and bindings.
An import generation guard rejects stale preview, file-read, and submission callbacks.
Closing before submission performs no writes. Closing after submission does not imply server cancellation.
Disable dismissal and repeated submission during the final request, then restore normal dismissal when it settles.
Failures retain draft input. Profile conflicts refresh preview and preserve only selections whose IDs and revisions remain valid.
An empty candidate list shows profile-settings access in a new tab and a Retry action, preserving the import draft.
Loading uses a localized status region. Success uses the existing toast and workspace refresh.
## Responsive composition
The entry point remains workspace workflow settings, through upload or paste.
Desktop uses a bounded Dialog with grouped rows, searchable profile popovers, and fixed footer actions.
The YAML and profile dialogs share a desktop maximum width of 48 rem.
The file chooser uses a compact secondary button, with a 28 px desktop height and 44 px touch height.
The YAML editor has a useful initial height and bounded scrolling.
Profile popovers start closed and open only after an explicit user action.
Selected profile triggers use one truncated line with the full label available through their title.
The candidate list retains separate profile-name and descriptor lines in content-sized rows.
Phone uses an inset, nearly full-height Drawer because a batch can contain many profiles and long descriptors.
The same drawer switches between the missing-step list and a focused searchable profile list with Back navigation.
This avoids stacked drawers. A selection returns to the missing-step list without losing earlier selections.
The nearest shipped geometry exemplar is `components/kanban/mobile-menu-sheet.tsx` and its `ResponsiveMenuSurface`.
It contributes dynamic viewport sizing, a fixed header, and one internal scroll owner.
`workflow-session-target-surfaces.tsx` provides the searchable profile row and agent-logo precedent.
The import picker must not expose that component's session targets or lifecycle controls.
Use `useResponsiveBreakpoint`, `@kandev/ui` Dialog/Drawer/Command primitives, and shared state across viewports.
Keep the header and footer fixed. The active body owns `min-h-0`, vertical scrolling, and overscroll containment.
Use dynamic viewport height and safe-area padding. Long labels wrap without horizontal overflow.
Fine-pointer desktop controls use 28 px sizing. Phone and coarse-pointer controls have at least 44 px hit targets.
Focus enters the active surface and returns to its trigger after dismissal.
Back or Escape exits the profile subview before dismissing the import surface.
## Verification and documentation
Service tests cover unresolved and selected variants of the supplied Feature workflow, exact ID retention, and whole-batch validation before writes.
Handler tests cover both content types, preview authorization, limits, and structured conflict responses.
Hook tests cover stale results, independent choices, retries, empty candidates, and duplicate submissions.
Desktop and phone E2E tests import through the UI and verify persisted selections and PR's target after reload.
Phone tests also verify focused picker navigation, containment, scroll ownership, and touch targets.
Public documentation changes belong in `docs/public/workflow-import-export.md` with the implementation.
It must distinguish the browser selection flow from legacy raw-YAML and unattended imports.
All new UI text uses locale keys for English, Portuguese, and the three Chinese locales, plus generated pseudo-locale content.
## Related contracts
- [Requirements](../requirements/workflow-import-profile-selection.md)
- [Session lifecycle and recipients](workflow-profile-session-lifecycle.md)
- [Profile disable behavior](../../agents/requirements/profile-disable.md)
- [Runtime model strictness](../../../decisions/2026-09-15-explicit-profile-model-strictness.md) remains separate from portable profile matching.