feat: add workflow import profile selection (#3771)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -667,6 +667,7 @@ func (c *Controller) ListHistoryBySession(ctx context.Context, req ListHistoryRe
|
||||
type ImportWorkflowsRequest struct {
|
||||
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 ©, 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 ©, 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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user