46678d6703
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
745 lines
30 KiB
Go
745 lines
30 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap"
|
|
"go.uber.org/zap/zapcore"
|
|
"go.uber.org/zap/zaptest/observer"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
taskmodels "github.com/kandev/kandev/internal/task/models"
|
|
"github.com/kandev/kandev/internal/workflow/models"
|
|
)
|
|
|
|
// mockSyncOps implements SyncWorkflowOps on top of a mockWorkflowProvider.
|
|
type mockSyncOps struct {
|
|
provider *mockWorkflowProvider
|
|
tasksPerWorkflow map[string]int
|
|
tasksPerStep map[string]int
|
|
deleted []string
|
|
}
|
|
|
|
func newMockSyncOps(provider *mockWorkflowProvider) *mockSyncOps {
|
|
return &mockSyncOps{
|
|
provider: provider,
|
|
tasksPerWorkflow: map[string]int{},
|
|
tasksPerStep: map[string]int{},
|
|
}
|
|
}
|
|
|
|
func (m *mockSyncOps) DeleteWorkflow(_ context.Context, id string) error {
|
|
for i, wf := range m.provider.workflows {
|
|
if wf.ID == id {
|
|
m.provider.workflows = append(m.provider.workflows[:i], m.provider.workflows[i+1:]...)
|
|
m.deleted = append(m.deleted, id)
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("workflow %s not found", id)
|
|
}
|
|
|
|
func (m *mockSyncOps) CountTasksByWorkflow(_ context.Context, workflowID string) (int, error) {
|
|
return m.tasksPerWorkflow[workflowID], nil
|
|
}
|
|
|
|
func (m *mockSyncOps) CountTasksByWorkflowStep(_ context.Context, stepID string) (int, error) {
|
|
return m.tasksPerStep[stepID], nil
|
|
}
|
|
|
|
func (m *mockSyncOps) SetWorkflowSource(ctx context.Context, id, source, sourcePath string) error {
|
|
wf, err := m.provider.GetWorkflow(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wf.Source = source
|
|
wf.SourcePath = sourcePath
|
|
return nil
|
|
}
|
|
|
|
func setupSyncService(t *testing.T) (*Service, *mockWorkflowProvider, *mockSyncOps) {
|
|
svc, _ := setupTestService(t)
|
|
provider := &mockWorkflowProvider{}
|
|
svc.SetWorkflowProvider(provider)
|
|
ops := newMockSyncOps(provider)
|
|
svc.SetSyncWorkflowOps(ops)
|
|
return svc, provider, ops
|
|
}
|
|
|
|
// setupSyncServiceWithLogger is a variant of setupSyncService for tests that
|
|
// need to observe log output (e.g. via a zaptest observer core).
|
|
func setupSyncServiceWithLogger(t *testing.T, log *logger.Logger) (*Service, *mockWorkflowProvider, *mockSyncOps) {
|
|
svc, _ := setupTestService(t, log)
|
|
provider := &mockWorkflowProvider{}
|
|
svc.SetWorkflowProvider(provider)
|
|
ops := newMockSyncOps(provider)
|
|
svc.SetSyncWorkflowOps(ops)
|
|
return svc, provider, ops
|
|
}
|
|
|
|
func portableWorkflow(name string, stepNames ...string) models.WorkflowPortable {
|
|
steps := make([]models.StepPortable, 0, len(stepNames))
|
|
for i, sn := range stepNames {
|
|
steps = append(steps, models.StepPortable{
|
|
Name: sn,
|
|
Position: i,
|
|
Color: "#aabbcc",
|
|
IsStartStep: i == 0,
|
|
})
|
|
}
|
|
return models.WorkflowPortable{Name: name, Description: "synced " + name, Steps: steps}
|
|
}
|
|
|
|
func exportOf(workflows ...models.WorkflowPortable) *models.WorkflowExport {
|
|
return &models.WorkflowExport{
|
|
Version: models.ExportVersion,
|
|
Type: models.ExportType,
|
|
Workflows: workflows,
|
|
}
|
|
}
|
|
|
|
func addSyncedWorkflow(provider *mockWorkflowProvider, id, workspaceID, name, sourcePath string) *taskmodels.Workflow {
|
|
provider.addWorkflow(id, workspaceID, name)
|
|
wf := provider.workflows[len(provider.workflows)-1]
|
|
wf.Source = taskmodels.WorkflowSourceGitHub
|
|
wf.SourcePath = sourcePath
|
|
return wf
|
|
}
|
|
|
|
func TestApplySyncedWorkflows_CreatesNewWorkflows(t *testing.T) {
|
|
svc, provider, _ := setupSyncService(t)
|
|
ctx := context.Background()
|
|
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", []SyncFileExport{
|
|
{Path: "flows/dev.yml", Export: exportOf(portableWorkflow("Dev Flow", "Todo", "Doing", "Done"))},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"Dev Flow"}, result.Created)
|
|
assert.Empty(t, result.Warnings)
|
|
|
|
wf, err := provider.GetWorkflow(ctx, "imported-Dev Flow")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, taskmodels.WorkflowSourceGitHub, wf.Source)
|
|
assert.Equal(t, "flows/dev.yml", wf.SourcePath)
|
|
|
|
steps, err := svc.ListStepsByWorkflow(ctx, wf.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, steps, 3)
|
|
assert.Equal(t, "Todo", steps[0].Name)
|
|
assert.True(t, steps[0].IsStartStep)
|
|
}
|
|
|
|
func TestApplySyncedWorkflows_UpdatesAndClearsWorkflowPrompt(t *testing.T) {
|
|
svc, provider, _ := setupSyncService(t)
|
|
ctx := context.Background()
|
|
wf := addSyncedWorkflow(provider, "wf-1", "ws-1", "Dev Flow", "flows/dev.yml")
|
|
wf.Prompt = "Old shared instructions."
|
|
createStep(t, svc, &models.WorkflowStep{ID: "step-todo", WorkflowID: wf.ID, Name: "Todo", Position: 0, IsStartStep: true})
|
|
|
|
// Update prompt from portable definition.
|
|
updated := portableWorkflow("Dev Flow", "Todo")
|
|
updated.Prompt = "If the PR is merged or closed, move the Task to Done."
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", []SyncFileExport{
|
|
{Path: "flows/dev.yml", Export: exportOf(updated)},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Contains(t, result.Updated, "Dev Flow")
|
|
|
|
got, err := provider.GetWorkflow(ctx, wf.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "If the PR is merged or closed, move the Task to Done.", got.Prompt)
|
|
|
|
// Omitting prompt in the portable file clears it on sync.
|
|
cleared := portableWorkflow("Dev Flow", "Todo")
|
|
cleared.Prompt = ""
|
|
result, err = svc.ApplySyncedWorkflows(ctx, "ws-1", []SyncFileExport{
|
|
{Path: "flows/dev.yml", Export: exportOf(cleared)},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Contains(t, result.Updated, "Dev Flow")
|
|
|
|
got, err = provider.GetWorkflow(ctx, wf.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "", got.Prompt)
|
|
}
|
|
|
|
func TestApplySyncedWorkflows_UpdatesMatchedWorkflowPreservingStepIDs(t *testing.T) {
|
|
svc, provider, _ := setupSyncService(t)
|
|
ctx := context.Background()
|
|
wf := addSyncedWorkflow(provider, "wf-1", "ws-1", "Dev Flow", "flows/dev.yml")
|
|
|
|
createStep(t, svc, &models.WorkflowStep{ID: "step-todo", WorkflowID: wf.ID, Name: "Todo", Position: 0, IsStartStep: true})
|
|
createStep(t, svc, &models.WorkflowStep{ID: "step-done", WorkflowID: wf.ID, Name: "Done", Position: 1})
|
|
|
|
// New definition: Todo keeps its identity but moves color/position,
|
|
// "Review" is inserted, "Done" survives at position 2.
|
|
pw := portableWorkflow("Dev Flow", "Todo", "Review", "Done")
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", []SyncFileExport{
|
|
{Path: "flows/dev.yml", Export: exportOf(pw)},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"Dev Flow"}, result.Updated)
|
|
assert.Empty(t, result.Created)
|
|
assert.Empty(t, result.Warnings)
|
|
assert.Equal(t, "synced Dev Flow", wf.Description)
|
|
|
|
steps, err := svc.ListStepsByWorkflow(ctx, wf.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, steps, 3)
|
|
byName := map[string]*models.WorkflowStep{}
|
|
for _, st := range steps {
|
|
byName[st.Name] = st
|
|
}
|
|
assert.Equal(t, "step-todo", byName["Todo"].ID, "matched step keeps its ID")
|
|
assert.Equal(t, "step-done", byName["Done"].ID, "matched step keeps its ID")
|
|
assert.Equal(t, 1, byName["Review"].Position)
|
|
assert.Equal(t, 2, byName["Done"].Position)
|
|
}
|
|
|
|
func TestApplySyncedWorkflows_RemovedStepWithTasksBlocksUpdate(t *testing.T) {
|
|
svc, provider, ops := setupSyncService(t)
|
|
ctx := context.Background()
|
|
wf := addSyncedWorkflow(provider, "wf-1", "ws-1", "Dev Flow", "flows/dev.yml")
|
|
createStep(t, svc, &models.WorkflowStep{ID: "step-todo", WorkflowID: wf.ID, Name: "Todo", Position: 0, IsStartStep: true})
|
|
createStep(t, svc, &models.WorkflowStep{ID: "step-legacy", WorkflowID: wf.ID, Name: "Legacy", Position: 1})
|
|
ops.tasksPerStep["step-legacy"] = 2
|
|
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", []SyncFileExport{
|
|
{Path: "flows/dev.yml", Export: exportOf(portableWorkflow("Dev Flow", "Todo"))},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, result.Updated)
|
|
require.Len(t, result.Warnings, 1)
|
|
assert.Contains(t, result.Warnings[0], "Legacy")
|
|
assert.Contains(t, result.Warnings[0], "2 task(s)")
|
|
|
|
steps, err := svc.ListStepsByWorkflow(ctx, wf.ID)
|
|
require.NoError(t, err)
|
|
assert.Len(t, steps, 2, "workflow left untouched")
|
|
}
|
|
|
|
func TestApplySyncedWorkflows_RemovedStepWithoutTasksIsDeleted(t *testing.T) {
|
|
svc, provider, _ := setupSyncService(t)
|
|
ctx := context.Background()
|
|
wf := addSyncedWorkflow(provider, "wf-1", "ws-1", "Dev Flow", "flows/dev.yml")
|
|
createStep(t, svc, &models.WorkflowStep{ID: "step-todo", WorkflowID: wf.ID, Name: "Todo", Position: 0, IsStartStep: true})
|
|
createStep(t, svc, &models.WorkflowStep{ID: "step-legacy", WorkflowID: wf.ID, Name: "Legacy", Position: 1})
|
|
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", []SyncFileExport{
|
|
{Path: "flows/dev.yml", Export: exportOf(portableWorkflow("Dev Flow", "Todo"))},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"Dev Flow"}, result.Updated)
|
|
assert.Empty(t, result.Warnings)
|
|
|
|
steps, err := svc.ListStepsByWorkflow(ctx, wf.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, steps, 1)
|
|
assert.Equal(t, "Todo", steps[0].Name)
|
|
}
|
|
|
|
func TestApplySyncedWorkflows_DeletesVanishedWorkflowWithoutTasks(t *testing.T) {
|
|
svc, provider, ops := setupSyncService(t)
|
|
ctx := context.Background()
|
|
addSyncedWorkflow(provider, "wf-gone", "ws-1", "Old Flow", "flows/old.yml")
|
|
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", nil)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"Old Flow"}, result.Deleted)
|
|
assert.Equal(t, []string{"wf-gone"}, ops.deleted)
|
|
}
|
|
|
|
func TestApplySyncedWorkflows_KeepsVanishedWorkflowWithTasks(t *testing.T) {
|
|
svc, provider, ops := setupSyncService(t)
|
|
ctx := context.Background()
|
|
addSyncedWorkflow(provider, "wf-gone", "ws-1", "Old Flow", "flows/old.yml")
|
|
ops.tasksPerWorkflow["wf-gone"] = 3
|
|
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", nil)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, result.Deleted)
|
|
require.Len(t, result.Warnings, 1)
|
|
assert.Contains(t, result.Warnings[0], "Old Flow")
|
|
assert.Empty(t, ops.deleted)
|
|
}
|
|
|
|
func TestApplySyncedWorkflows_BrokenFileFreezesItsWorkflows(t *testing.T) {
|
|
svc, provider, ops := setupSyncService(t)
|
|
ctx := context.Background()
|
|
addSyncedWorkflow(provider, "wf-frozen", "ws-1", "Frozen Flow", "flows/broken.yml")
|
|
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", []SyncFileExport{
|
|
{Path: "flows/broken.yml", Export: nil},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, result.Deleted)
|
|
assert.Empty(t, ops.deleted, "workflows from unparseable files must not be deleted")
|
|
}
|
|
|
|
func TestApplySyncedWorkflows_LeavesManualWorkflowsAlone(t *testing.T) {
|
|
svc, provider, ops := setupSyncService(t)
|
|
ctx := context.Background()
|
|
provider.addWorkflow("wf-manual", "ws-1", "Dev Flow") // manual workflow, same name
|
|
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", []SyncFileExport{
|
|
{Path: "flows/dev.yml", Export: exportOf(portableWorkflow("Dev Flow", "Todo"))},
|
|
})
|
|
require.NoError(t, err)
|
|
// A synced sibling is created; the manual workflow is neither updated nor deleted.
|
|
assert.Equal(t, []string{"Dev Flow"}, result.Created)
|
|
assert.Empty(t, ops.deleted)
|
|
|
|
manual, err := provider.GetWorkflow(ctx, "wf-manual")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "", manual.Source)
|
|
assert.Equal(t, "", manual.Description)
|
|
}
|
|
|
|
func TestApplySyncedWorkflows_DuplicateStepNamesInDefinitionWarn(t *testing.T) {
|
|
svc, provider, _ := setupSyncService(t)
|
|
ctx := context.Background()
|
|
wf := addSyncedWorkflow(provider, "wf-1", "ws-1", "Dev Flow", "flows/dev.yml")
|
|
createStep(t, svc, &models.WorkflowStep{ID: "step-todo", WorkflowID: wf.ID, Name: "Todo", Position: 0, IsStartStep: true})
|
|
|
|
pw := models.WorkflowPortable{
|
|
Name: "Dev Flow",
|
|
Steps: []models.StepPortable{
|
|
{Name: "Todo", Position: 0},
|
|
{Name: "Todo", Position: 1},
|
|
},
|
|
}
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", []SyncFileExport{
|
|
{Path: "flows/dev.yml", Export: exportOf(pw)},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, result.Updated)
|
|
require.Len(t, result.Warnings, 1)
|
|
assert.Contains(t, result.Warnings[0], "must be unique")
|
|
}
|
|
|
|
func TestApplySyncedWorkflows_RemapsStepEventPositions(t *testing.T) {
|
|
svc, _, _ := setupSyncService(t)
|
|
ctx := context.Background()
|
|
|
|
pw := models.WorkflowPortable{
|
|
Name: "Flow",
|
|
Steps: []models.StepPortable{
|
|
{
|
|
Name: "Start", Position: 0, IsStartStep: true,
|
|
Events: models.StepEvents{
|
|
OnTurnComplete: []models.OnTurnCompleteAction{
|
|
{Type: models.OnTurnCompleteMoveToStep, Config: map[string]any{"step_position": 1}},
|
|
},
|
|
},
|
|
},
|
|
{Name: "End", Position: 1},
|
|
},
|
|
}
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", []SyncFileExport{
|
|
{Path: "flows/flow.yml", Export: exportOf(pw)},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, []string{"Flow"}, result.Created)
|
|
|
|
steps, err := svc.ListStepsByWorkflow(ctx, "imported-Flow")
|
|
require.NoError(t, err)
|
|
require.Len(t, steps, 2)
|
|
require.Len(t, steps[0].Events.OnTurnComplete, 1)
|
|
assert.Equal(t, steps[1].ID, steps[0].Events.OnTurnComplete[0].Config["step_id"])
|
|
}
|
|
|
|
func TestApplySyncedWorkflows_PreservesCancelTriggersTurnComplete(t *testing.T) {
|
|
svc, _, _ := setupSyncService(t)
|
|
ctx := context.Background()
|
|
first := models.WorkflowPortable{
|
|
Name: "Cancel Flow",
|
|
Steps: []models.StepPortable{
|
|
{Name: "Work", Position: 0, IsStartStep: true, CancelTriggersTurnComplete: true},
|
|
{Name: "Review", Position: 1},
|
|
},
|
|
}
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", []SyncFileExport{{Path: "flows/cancel.yml", Export: exportOf(first)}})
|
|
require.NoError(t, err)
|
|
require.Equal(t, []string{"Cancel Flow"}, result.Created)
|
|
|
|
steps, err := svc.ListStepsByWorkflow(ctx, "imported-Cancel Flow")
|
|
require.NoError(t, err)
|
|
require.Len(t, steps, 2)
|
|
assert.True(t, steps[0].CancelTriggersTurnComplete)
|
|
|
|
updated := first
|
|
updated.Steps = append([]models.StepPortable(nil), first.Steps...)
|
|
updated.Steps[0].CancelTriggersTurnComplete = false
|
|
result, err = svc.ApplySyncedWorkflows(ctx, "ws-1", []SyncFileExport{{Path: "flows/cancel.yml", Export: exportOf(updated)}})
|
|
require.NoError(t, err)
|
|
require.Equal(t, []string{"Cancel Flow"}, result.Updated)
|
|
steps, err = svc.ListStepsByWorkflow(ctx, "imported-Cancel Flow")
|
|
require.NoError(t, err)
|
|
assert.False(t, steps[0].CancelTriggersTurnComplete)
|
|
}
|
|
|
|
func TestApplySyncedWorkflows_SecondApplyIsNoOp(t *testing.T) {
|
|
svc, _, _ := setupSyncService(t)
|
|
ctx := context.Background()
|
|
files := []SyncFileExport{
|
|
{Path: "flows/dev.yml", Export: exportOf(portableWorkflow("Dev Flow", "Todo", "Done"))},
|
|
}
|
|
|
|
first, err := svc.ApplySyncedWorkflows(ctx, "ws-1", files)
|
|
require.NoError(t, err)
|
|
require.Equal(t, []string{"Dev Flow"}, first.Created)
|
|
|
|
second, err := svc.ApplySyncedWorkflows(ctx, "ws-1", files)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, second.Created)
|
|
assert.Empty(t, second.Updated, "no drift means nothing is rewritten or broadcast")
|
|
assert.Empty(t, second.Deleted)
|
|
assert.Empty(t, second.Warnings)
|
|
}
|
|
|
|
func TestApplySyncedWorkflows_RepairsLocalStepEdit(t *testing.T) {
|
|
svc, _, _ := setupSyncService(t)
|
|
ctx := context.Background()
|
|
files := []SyncFileExport{
|
|
{Path: "flows/dev.yml", Export: exportOf(portableWorkflow("Dev Flow", "Todo", "Done"))},
|
|
}
|
|
_, err := svc.ApplySyncedWorkflows(ctx, "ws-1", files)
|
|
require.NoError(t, err)
|
|
|
|
// A user recolors a synced step in the UI.
|
|
steps, err := svc.ListStepsByWorkflow(ctx, "imported-Dev Flow")
|
|
require.NoError(t, err)
|
|
steps[0].Color = "#123456"
|
|
require.NoError(t, svc.repo.UpdateStep(ctx, steps[0]))
|
|
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", files)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"Dev Flow"}, result.Updated, "local drift is repaired")
|
|
|
|
steps, err = svc.ListStepsByWorkflow(ctx, "imported-Dev Flow")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "#aabbcc", steps[0].Color, "definition wins over the local edit")
|
|
}
|
|
|
|
func TestApplySyncedWorkflows_RecreatesLocallyDeletedStep(t *testing.T) {
|
|
svc, _, _ := setupSyncService(t)
|
|
ctx := context.Background()
|
|
files := []SyncFileExport{
|
|
{Path: "flows/dev.yml", Export: exportOf(portableWorkflow("Dev Flow", "Todo", "Done"))},
|
|
}
|
|
_, err := svc.ApplySyncedWorkflows(ctx, "ws-1", files)
|
|
require.NoError(t, err)
|
|
|
|
steps, err := svc.ListStepsByWorkflow(ctx, "imported-Dev Flow")
|
|
require.NoError(t, err)
|
|
require.Len(t, steps, 2)
|
|
require.NoError(t, svc.DeleteStep(ctx, steps[1].ID))
|
|
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", files)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"Dev Flow"}, result.Updated)
|
|
|
|
steps, err = svc.ListStepsByWorkflow(ctx, "imported-Dev Flow")
|
|
require.NoError(t, err)
|
|
require.Len(t, steps, 2, "locally deleted step is recreated from the definition")
|
|
assert.Equal(t, "Done", steps[1].Name)
|
|
}
|
|
|
|
func TestEnsureWorkflowMutable(t *testing.T) {
|
|
svc, provider, _ := setupSyncService(t)
|
|
ctx := context.Background()
|
|
addSyncedWorkflow(provider, "wf-synced", "ws-1", "Synced Flow", "flows/dev.yml")
|
|
provider.addWorkflow("wf-manual", "ws-1", "Manual Flow")
|
|
|
|
assert.ErrorIs(t, svc.EnsureWorkflowMutable(ctx, "wf-synced"), ErrWorkflowReadOnly)
|
|
assert.NoError(t, svc.EnsureWorkflowMutable(ctx, "wf-manual"))
|
|
assert.NoError(t, svc.EnsureWorkflowMutable(ctx, "wf-missing"),
|
|
"guard fails open on lookup errors; the mutation itself surfaces not-found")
|
|
}
|
|
|
|
func TestEnsureWorkflowMutable_BlocksImproveWorkspace(t *testing.T) {
|
|
svc, provider, _ := setupSyncService(t)
|
|
ctx := context.Background()
|
|
provider.addWorkflow("wf-improve", "ws-improve", "Improve Kandev Workflow")
|
|
svc.SetWorkspaceProvider(&fakeWorkspaceProvider{
|
|
workspaces: map[string]*taskmodels.Workspace{
|
|
"ws-improve": {ID: "ws-improve", Name: "Improve Kandev"},
|
|
"ws-normal": {ID: "ws-normal", Name: "Default Workspace"},
|
|
},
|
|
})
|
|
provider.addWorkflow("wf-normal", "ws-normal", "Normal Flow")
|
|
|
|
assert.ErrorIs(t, svc.EnsureWorkflowMutable(ctx, "wf-improve"), ErrWorkflowWorkspaceReadOnly)
|
|
assert.NoError(t, svc.EnsureWorkflowMutable(ctx, "wf-normal"))
|
|
}
|
|
|
|
type fakeWorkspaceProvider struct {
|
|
workspaces map[string]*taskmodels.Workspace
|
|
}
|
|
|
|
func (f *fakeWorkspaceProvider) GetWorkspace(_ context.Context, id string) (*taskmodels.Workspace, error) {
|
|
if workspace, ok := f.workspaces[id]; ok {
|
|
return workspace, nil
|
|
}
|
|
return nil, fmt.Errorf("workspace not found: %s", id)
|
|
}
|
|
|
|
func TestReleaseSyncedWorkflows(t *testing.T) {
|
|
svc, provider, _ := setupSyncService(t)
|
|
ctx := context.Background()
|
|
addSyncedWorkflow(provider, "wf-a", "ws-1", "Flow A", "flows/a.yml")
|
|
addSyncedWorkflow(provider, "wf-b", "ws-1", "Flow B", "flows/b.yml")
|
|
provider.addWorkflow("wf-manual", "ws-1", "Manual Flow")
|
|
|
|
released, err := svc.ReleaseSyncedWorkflows(ctx, "ws-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"Flow A", "Flow B"}, released)
|
|
|
|
for _, id := range []string{"wf-a", "wf-b"} {
|
|
wf, err := provider.GetWorkflow(ctx, id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, taskmodels.WorkflowSourceManual, wf.Source, "released workflows become manual")
|
|
assert.Empty(t, wf.SourcePath)
|
|
assert.NoError(t, svc.EnsureWorkflowMutable(ctx, id), "released workflows are editable again")
|
|
}
|
|
}
|
|
|
|
// TestApplySyncedWorkflows_RebindingStepAgentProfileLogsWarning covers the
|
|
// visibility half of the profile-rebinding defect: a sync round that changes
|
|
// an existing step's AgentProfileID must warn (naming the workflow, step,
|
|
// and both profile IDs) since the workflow is read-only to the user and the
|
|
// change would otherwise be silent. A second sync that resolves to the same
|
|
// profile must not add another warning.
|
|
func TestApplySyncedWorkflows_RebindingStepAgentProfileLogsWarning(t *testing.T) {
|
|
core, logs := observer.New(zapcore.WarnLevel)
|
|
log, err := logger.NewFromZap(zap.New(core))
|
|
require.NoError(t, err)
|
|
|
|
svc, provider, _ := setupSyncServiceWithLogger(t, log)
|
|
ctx := context.Background()
|
|
wf := addSyncedWorkflow(provider, "wf-1", "ws-1", "Dev Flow", "flows/dev.yml")
|
|
createStep(t, svc, &models.WorkflowStep{
|
|
ID: "step-todo", WorkflowID: wf.ID, Name: "Todo", Position: 0, IsStartStep: true,
|
|
AgentProfileID: "profile-old",
|
|
})
|
|
|
|
pw := portableWorkflow("Dev Flow", "Todo")
|
|
pw.Steps[0].AgentProfile = &models.AgentProfilePortable{AgentName: "Claude", Model: "opus[1m]", Mode: "auto"}
|
|
files := []SyncFileExport{{Path: "flows/dev.yml", Export: exportOf(pw)}}
|
|
|
|
svc.SetAgentProfileFuncs(nil, func(string, string, string, string) string { return "profile-new" })
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", files)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"Dev Flow"}, result.Updated)
|
|
|
|
entries := logs.FilterMessage("workflow sync rebinding step's agent profile").All()
|
|
require.Len(t, entries, 1, "rebind must be logged exactly once")
|
|
fields := entries[0].ContextMap()
|
|
assert.Equal(t, "step-todo", fields["step_id"])
|
|
assert.Equal(t, "Todo", fields["step_name"])
|
|
assert.Equal(t, "profile-old", fields["old_agent_profile_id"])
|
|
assert.Equal(t, "profile-new", fields["new_agent_profile_id"])
|
|
|
|
// A second sync resolving to the same profile must not warn again.
|
|
result, err = svc.ApplySyncedWorkflows(ctx, "ws-1", files)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, result.Updated, "no drift means nothing is rewritten")
|
|
assert.Len(t, logs.FilterMessage("workflow sync rebinding step's agent profile").All(), 1,
|
|
"an unchanged profile must not log another warning")
|
|
}
|
|
|
|
// TestApplySyncedWorkflows_RebindWarningDoesNotFireOnFailedUpdate covers
|
|
// R1-F1: the rebind Warn must only fire once the change is actually
|
|
// persisted. It forces the step's UpdateStep write to fail (via PRAGMA
|
|
// query_only, which makes every write on this connection fail while reads —
|
|
// like the ListStepsByWorkflow that already ran — keep working) and asserts
|
|
// no Warn is logged for a rebind that never landed.
|
|
func TestApplySyncedWorkflows_RebindWarningDoesNotFireOnFailedUpdate(t *testing.T) {
|
|
core, logs := observer.New(zapcore.WarnLevel)
|
|
log, err := logger.NewFromZap(zap.New(core))
|
|
require.NoError(t, err)
|
|
|
|
svc, db := setupTestService(t, log)
|
|
provider := &mockWorkflowProvider{}
|
|
svc.SetWorkflowProvider(provider)
|
|
ops := newMockSyncOps(provider)
|
|
svc.SetSyncWorkflowOps(ops)
|
|
|
|
ctx := context.Background()
|
|
wf := addSyncedWorkflow(provider, "wf-1", "ws-1", "Dev Flow", "flows/dev.yml")
|
|
createStep(t, svc, &models.WorkflowStep{
|
|
ID: "step-todo", WorkflowID: wf.ID, Name: "Todo", Position: 0, IsStartStep: true,
|
|
AgentProfileID: "profile-old",
|
|
})
|
|
|
|
pw := portableWorkflow("Dev Flow", "Todo")
|
|
pw.Steps[0].AgentProfile = &models.AgentProfilePortable{AgentName: "Claude", Model: "opus[1m]", Mode: "auto"}
|
|
files := []SyncFileExport{{Path: "flows/dev.yml", Export: exportOf(pw)}}
|
|
svc.SetAgentProfileFuncs(nil, func(string, string, string, string) string { return "profile-new" })
|
|
|
|
_, err = db.Exec("PRAGMA query_only = ON")
|
|
t.Cleanup(func() { _, _ = db.Exec("PRAGMA query_only = OFF") })
|
|
require.NoError(t, err)
|
|
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", files)
|
|
require.NoError(t, err)
|
|
require.Len(t, result.Warnings, 1, "the failed step write surfaces a warning")
|
|
assert.Contains(t, result.Warnings[0], "Todo")
|
|
assert.Empty(t, result.Updated, "a step update that failed to persist must not be reported as applied")
|
|
assert.Empty(t, logs.FilterMessage("workflow sync rebinding step's agent profile").All(),
|
|
"a rebind Warn must never fire for a change that was not actually persisted")
|
|
}
|
|
|
|
// TestApplySyncedWorkflows_RebindingWorkflowAgentProfileLogsWarning covers
|
|
// R1-F2: the workflow-level AgentProfileID (the fallback steps with no
|
|
// profile of their own resolve to) is rebound by the same matcher and must
|
|
// be logged just like a step-level rebind.
|
|
func TestApplySyncedWorkflows_RebindingWorkflowAgentProfileLogsWarning(t *testing.T) {
|
|
core, logs := observer.New(zapcore.WarnLevel)
|
|
log, err := logger.NewFromZap(zap.New(core))
|
|
require.NoError(t, err)
|
|
|
|
svc, provider, _ := setupSyncServiceWithLogger(t, log)
|
|
ctx := context.Background()
|
|
wf := addSyncedWorkflow(provider, "wf-1", "ws-1", "Dev Flow", "flows/dev.yml")
|
|
wf.AgentProfileID = "profile-old"
|
|
createStep(t, svc, &models.WorkflowStep{ID: "step-todo", WorkflowID: wf.ID, Name: "Todo", Position: 0, IsStartStep: true})
|
|
|
|
pw := portableWorkflow("Dev Flow", "Todo")
|
|
pw.AgentProfile = &models.AgentProfilePortable{AgentName: "Claude", Model: "opus[1m]", Mode: "auto"}
|
|
files := []SyncFileExport{{Path: "flows/dev.yml", Export: exportOf(pw)}}
|
|
|
|
svc.SetAgentProfileFuncs(nil, func(string, string, string, string) string { return "profile-new" })
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", files)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"Dev Flow"}, result.Updated)
|
|
|
|
got, err := provider.GetWorkflow(ctx, wf.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "profile-new", got.AgentProfileID)
|
|
|
|
entries := logs.FilterMessage("workflow sync rebinding workflow's agent profile").All()
|
|
require.Len(t, entries, 1, "workflow-level rebind must be logged exactly once")
|
|
fields := entries[0].ContextMap()
|
|
assert.Equal(t, "wf-1", fields["workflow_id"])
|
|
assert.Equal(t, "Dev Flow", fields["workflow_name"])
|
|
assert.Equal(t, "profile-old", fields["old_agent_profile_id"])
|
|
assert.Equal(t, "profile-new", fields["new_agent_profile_id"])
|
|
|
|
// A second sync resolving to the same profile must not warn again.
|
|
result, err = svc.ApplySyncedWorkflows(ctx, "ws-1", files)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, result.Updated, "no drift means nothing is rewritten")
|
|
assert.Len(t, logs.FilterMessage("workflow sync rebinding workflow's agent profile").All(), 1,
|
|
"an unchanged profile must not log another warning")
|
|
}
|
|
|
|
// TestApplySyncedWorkflows_WorkflowRebindWarningDoesNotFireOnFailedUpdate is
|
|
// the workflow-level counterpart to
|
|
// TestApplySyncedWorkflows_RebindWarningDoesNotFireOnFailedUpdate (R1-F1): a
|
|
// rebind Warn must never fire for a workflow-level profile change that failed
|
|
// to persist.
|
|
func TestApplySyncedWorkflows_WorkflowRebindWarningDoesNotFireOnFailedUpdate(t *testing.T) {
|
|
core, logs := observer.New(zapcore.WarnLevel)
|
|
log, err := logger.NewFromZap(zap.New(core))
|
|
require.NoError(t, err)
|
|
|
|
svc, provider, _ := setupSyncServiceWithLogger(t, log)
|
|
ctx := context.Background()
|
|
wf := addSyncedWorkflow(provider, "wf-1", "ws-1", "Dev Flow", "flows/dev.yml")
|
|
wf.AgentProfileID = "profile-old"
|
|
createStep(t, svc, &models.WorkflowStep{ID: "step-todo", WorkflowID: wf.ID, Name: "Todo", Position: 0, IsStartStep: true})
|
|
|
|
pw := portableWorkflow("Dev Flow", "Todo")
|
|
pw.AgentProfile = &models.AgentProfilePortable{AgentName: "Claude", Model: "opus[1m]", Mode: "auto"}
|
|
files := []SyncFileExport{{Path: "flows/dev.yml", Export: exportOf(pw)}}
|
|
svc.SetAgentProfileFuncs(nil, func(string, string, string, string) string { return "profile-new" })
|
|
|
|
provider.forceUpdateWorkflowErr = fmt.Errorf("simulated UpdateWorkflow failure")
|
|
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", files)
|
|
require.NoError(t, err)
|
|
require.Len(t, result.Warnings, 1, "the failed workflow write surfaces a warning")
|
|
assert.Contains(t, result.Warnings[0], "Dev Flow")
|
|
assert.Empty(t, result.Updated, "a workflow update that failed to persist must not be reported as applied")
|
|
assert.Empty(t, logs.FilterMessage("workflow sync rebinding workflow's agent profile").All(),
|
|
"a rebind Warn must never fire for a workflow change that was not actually persisted")
|
|
}
|
|
|
|
// TestApplySyncedWorkflows_StepMatcherReceivesCurrentBinding proves
|
|
// updateSyncedWorkflow passes the step's existing AgentProfileID to the
|
|
// matcher as currentID. A real matcher (buildAgentProfileMatcher) uses that
|
|
// to preserve a binding that was disabled after the fact instead of treating
|
|
// disablement as a rebind (docs/specs/agents/profile-disable.md). This test
|
|
// stubs that same contract: the matcher echoes currentID back when given
|
|
// one, standing in for "the bound profile still matches, keep it".
|
|
func TestApplySyncedWorkflows_StepMatcherReceivesCurrentBinding(t *testing.T) {
|
|
core, logs := observer.New(zapcore.WarnLevel)
|
|
log, err := logger.NewFromZap(zap.New(core))
|
|
require.NoError(t, err)
|
|
|
|
svc, provider, _ := setupSyncServiceWithLogger(t, log)
|
|
ctx := context.Background()
|
|
wf := addSyncedWorkflow(provider, "wf-1", "ws-1", "Dev Flow", "flows/dev.yml")
|
|
wf.Description = "synced Dev Flow" // must already match pw's description below, or the workflow itself counts as changed
|
|
createStep(t, svc, &models.WorkflowStep{
|
|
ID: "step-todo", WorkflowID: wf.ID, Name: "Todo", Position: 0, Color: "#aabbcc", IsStartStep: true,
|
|
AgentProfileID: "profile-disabled",
|
|
})
|
|
|
|
pw := portableWorkflow("Dev Flow", "Todo")
|
|
pw.Steps[0].AgentProfile = &models.AgentProfilePortable{AgentName: "Claude", Model: "opus[1m]", Mode: "auto"}
|
|
files := []SyncFileExport{{Path: "flows/dev.yml", Export: exportOf(pw)}}
|
|
|
|
svc.SetAgentProfileFuncs(nil, func(_, _, _, currentID string) string {
|
|
if currentID != "" {
|
|
return currentID
|
|
}
|
|
return "profile-new"
|
|
})
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", files)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, result.Updated, "a preserved binding must not be reported as a change")
|
|
assert.Empty(t, logs.FilterMessage("workflow sync rebinding step's agent profile").All(),
|
|
"a preserved binding must not log a rebind")
|
|
}
|
|
|
|
// TestApplySyncedWorkflows_StepProfileUnbindWarnsWithEmptyNewID covers a
|
|
// genuine unbind: the matcher finds no candidate at all (not the
|
|
// still-matches-but-disabled case above), so the step's AgentProfileID
|
|
// resolves to "". The rebind Warn must still fire and its
|
|
// new_agent_profile_id field must be observably empty, so a reader of the
|
|
// log can tell the step lost its profile rather than gained a different one.
|
|
func TestApplySyncedWorkflows_StepProfileUnbindWarnsWithEmptyNewID(t *testing.T) {
|
|
core, logs := observer.New(zapcore.WarnLevel)
|
|
log, err := logger.NewFromZap(zap.New(core))
|
|
require.NoError(t, err)
|
|
|
|
svc, provider, _ := setupSyncServiceWithLogger(t, log)
|
|
ctx := context.Background()
|
|
wf := addSyncedWorkflow(provider, "wf-1", "ws-1", "Dev Flow", "flows/dev.yml")
|
|
createStep(t, svc, &models.WorkflowStep{
|
|
ID: "step-todo", WorkflowID: wf.ID, Name: "Todo", Position: 0, IsStartStep: true,
|
|
AgentProfileID: "profile-old",
|
|
})
|
|
|
|
pw := portableWorkflow("Dev Flow", "Todo")
|
|
pw.Steps[0].AgentProfile = &models.AgentProfilePortable{AgentName: "Claude", Model: "opus[1m]", Mode: "auto"}
|
|
files := []SyncFileExport{{Path: "flows/dev.yml", Export: exportOf(pw)}}
|
|
|
|
svc.SetAgentProfileFuncs(nil, func(string, string, string, string) string { return "" })
|
|
result, err := svc.ApplySyncedWorkflows(ctx, "ws-1", files)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"Dev Flow"}, result.Updated)
|
|
|
|
entries := logs.FilterMessage("workflow sync rebinding step's agent profile").All()
|
|
require.Len(t, entries, 1, "an unbind must still be logged")
|
|
fields := entries[0].ContextMap()
|
|
assert.Equal(t, "profile-old", fields["old_agent_profile_id"])
|
|
assert.Equal(t, "", fields["new_agent_profile_id"])
|
|
}
|