From 10996c1b57e2660edf1a98c5eaedf93ffd39758c Mon Sep 17 00:00:00 2001 From: Luan Martins Date: Fri, 7 Aug 2026 15:22:44 +0100 Subject: [PATCH] feat: add optional workflow-level agent prompt (#2338) --- .../internal/backendapp/orchestrator.go | 5 + apps/backend/internal/backendapp/services.go | 2 + .../workflow_provider_adapter_test.go | 59 ++++++ .../orchestrator/event_handlers_test.go | 13 +- apps/backend/internal/orchestrator/service.go | 3 + .../internal/orchestrator/task_operations.go | 48 +++++ .../orchestrator/workflow_prompt_test.go | 175 ++++++++++++++++++ apps/backend/internal/task/dto/dto.go | 6 + apps/backend/internal/task/dto/requests.go | 2 + .../task/handlers/workflow_handlers.go | 8 + apps/backend/internal/task/models/models.go | 7 +- .../task/repository/sqlite/base_migrations.go | 1 + .../task/repository/sqlite/base_schema.go | 1 + .../task/repository/sqlite/workflow.go | 13 +- .../repository/workflow_repository_test.go | 74 ++++++++ .../internal/task/service/service_requests.go | 2 + .../task/service/service_resources.go | 4 + .../internal/workflow/models/export.go | 2 + .../internal/workflow/models/export_test.go | 6 +- .../internal/workflow/service/service.go | 24 ++- .../internal/workflow/service/service_test.go | 27 +++ .../internal/workflow/service/sync_apply.go | 3 +- .../workflow/service/sync_apply_test.go | 34 ++++ apps/backend/pkg/api/v1/workflow.go | 3 + apps/web/app/actions/workspaces.ts | 3 +- .../workspace/workspace-workflows-client.tsx | 3 +- .../use-workflow-draft-contributor.ts | 2 +- .../settings/workflow-card-actions.ts | 2 + .../web/components/settings/workflow-card.tsx | 9 + .../settings/workflow-dirty-state.test.ts | 8 + .../settings/workflow-dirty-state.ts | 2 +- .../settings/workflow-prompt-section.test.tsx | 70 +++++++ .../settings/workflow-prompt-section.tsx | 84 +++++++++ .../task/chat/messages/chat-message.tsx | 52 +----- .../task/chat/messages/user-message-body.tsx | 147 +++++++++++++++ apps/web/e2e/helpers/api-client.ts | 2 +- .../domains/settings/use-workflow-settings.ts | 1 + apps/web/lib/types/backend.ts | 1 + apps/web/lib/types/http.ts | 3 + .../lib/utils/workflow-instructions.test.ts | 90 +++++++++ apps/web/lib/utils/workflow-instructions.ts | 74 ++++++++ apps/web/src/locales/en/workflows.json | 6 + apps/web/src/locales/pseudo/workflows.json | 6 + docs/workflow-import-export.md | 3 + 44 files changed, 1020 insertions(+), 70 deletions(-) create mode 100644 apps/backend/internal/backendapp/workflow_provider_adapter_test.go create mode 100644 apps/web/components/settings/workflow-prompt-section.test.tsx create mode 100644 apps/web/components/settings/workflow-prompt-section.tsx create mode 100644 apps/web/components/task/chat/messages/user-message-body.tsx create mode 100644 apps/web/lib/utils/workflow-instructions.test.ts create mode 100644 apps/web/lib/utils/workflow-instructions.ts diff --git a/apps/backend/internal/backendapp/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go index def49e523f..42141acf53 100644 --- a/apps/backend/internal/backendapp/orchestrator.go +++ b/apps/backend/internal/backendapp/orchestrator.go @@ -369,6 +369,11 @@ func (a *orchestratorWorkflowStepGetterAdapter) GetWorkflowAgentProfileID(ctx co return a.svc.GetWorkflowAgentProfileID(ctx, workflowID) } +// GetWorkflowPrompt implements orchestrator.WorkflowStepGetter. +func (a *orchestratorWorkflowStepGetterAdapter) GetWorkflowPrompt(ctx context.Context, workflowID string) (string, error) { + return a.svc.GetWorkflowPrompt(ctx, workflowID) +} + // reviewTaskCreatorAdapter adapts the task service to the orchestrator's ReviewTaskCreator interface. type reviewTaskCreatorAdapter struct { svc *taskservice.Service diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 30f07cb0a6..36c06ececf 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -806,9 +806,11 @@ func (a *workflowProviderAdapter) CreateWorkflow(ctx context.Context, workspaceI // UpdateWorkflow implements workflowservice.WorkflowProvider. func (a *workflowProviderAdapter) UpdateWorkflow(ctx context.Context, workflow *taskmodels.Workflow) error { + prompt := workflow.Prompt _, err := a.svc.UpdateWorkflow(ctx, workflow.ID, &taskservice.UpdateWorkflowRequest{ Name: &workflow.Name, Description: &workflow.Description, + Prompt: &prompt, AgentProfileID: &workflow.AgentProfileID, }) return err diff --git a/apps/backend/internal/backendapp/workflow_provider_adapter_test.go b/apps/backend/internal/backendapp/workflow_provider_adapter_test.go new file mode 100644 index 0000000000..fad297ea7b --- /dev/null +++ b/apps/backend/internal/backendapp/workflow_provider_adapter_test.go @@ -0,0 +1,59 @@ +package backendapp + +import ( + "context" + "testing" + + taskservice "github.com/kandev/kandev/internal/task/service" +) + +// Production workflowProviderAdapter must forward Prompt on UpdateWorkflow. +// Before this fix, import/sync assigned Prompt on the in-memory workflow then +// called UpdateWorkflow, which dropped it — so the DB never received the field. +func TestWorkflowProviderAdapter_UpdateWorkflowPersistsPrompt(t *testing.T) { + harness := newBootStateTestHarness(t) + ctx := context.Background() + + workspaces, err := harness.taskSvc.ListWorkspaces(ctx) + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + if len(workspaces) == 0 { + t.Fatal("expected seeded default workspace") + } + + created, err := harness.taskSvc.CreateWorkflow(ctx, &taskservice.CreateWorkflowRequest{ + WorkspaceID: workspaces[0].ID, + Name: "Adapter Prompt WF", + }) + if err != nil { + t.Fatalf("CreateWorkflow: %v", err) + } + + adapter := &workflowProviderAdapter{svc: harness.taskSvc} + created.Prompt = "If the PR is merged or closed, move the Task to Done." + if err := adapter.UpdateWorkflow(ctx, created); err != nil { + t.Fatalf("UpdateWorkflow: %v", err) + } + + got, err := harness.taskSvc.GetWorkflow(ctx, created.ID) + if err != nil { + t.Fatalf("GetWorkflow: %v", err) + } + if got.Prompt != created.Prompt { + t.Fatalf("Prompt = %q, want %q", got.Prompt, created.Prompt) + } + + // Clear through the adapter must persist empty, not leave stale text. + created.Prompt = "" + if err := adapter.UpdateWorkflow(ctx, created); err != nil { + t.Fatalf("UpdateWorkflow clear: %v", err) + } + got, err = harness.taskSvc.GetWorkflow(ctx, created.ID) + if err != nil { + t.Fatalf("GetWorkflow after clear: %v", err) + } + if got.Prompt != "" { + t.Fatalf("Prompt after clear = %q, want empty", got.Prompt) + } +} diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index b63b9e9a86..6e45b8a564 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -40,10 +40,14 @@ import ( type mockStepGetter struct { steps map[string]*wfmodels.WorkflowStep // stepID -> step workflowAgentProfileID string // returned by GetWorkflowAgentProfileID + workflowPrompts map[string]string // workflowID -> prompt } func newMockStepGetter() *mockStepGetter { - return &mockStepGetter{steps: make(map[string]*wfmodels.WorkflowStep)} + return &mockStepGetter{ + steps: make(map[string]*wfmodels.WorkflowStep), + workflowPrompts: make(map[string]string), + } } func (m *mockStepGetter) GetStep(_ context.Context, stepID string) (*wfmodels.WorkflowStep, error) { @@ -84,6 +88,13 @@ func (m *mockStepGetter) GetWorkflowAgentProfileID(_ context.Context, workflowID return "", nil } +func (m *mockStepGetter) GetWorkflowPrompt(_ context.Context, workflowID string) (string, error) { + if m.workflowPrompts == nil { + return "", nil + } + return m.workflowPrompts[workflowID], nil +} + // mockTaskRepo implements scheduler.TaskRepository for testing. type mockTaskRepo struct { mu sync.Mutex diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index ab76060921..fd2086240a 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -151,6 +151,9 @@ type WorkflowStepGetter interface { GetNextStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) GetPreviousStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) GetWorkflowAgentProfileID(ctx context.Context, workflowID string) (string, error) + // GetWorkflowPrompt returns the optional workflow-level agent instructions + // prepended at step entry. Empty string means the workflow has no prompt. + GetWorkflowPrompt(ctx context.Context, workflowID string) (string, error) } // PromptReferenceExpander resolves "@name" saved-prompt references embedded in diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index c3d8d676a2..3da4871256 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -1442,6 +1442,16 @@ func (s *Service) buildWorkflowPrompt(ctx context.Context, basePrompt string, st return prompt } +// workflowInstructionsHeading/End are stable, agent-facing markers for the +// optional workflow-level prompt block. Chat collapses everything between +// them by default. Do not i18n (sent to the model, same as step prompt English). +// The end marker is required so multi-paragraph workflow prompts do not break +// the frontend split (a first-blank-line heuristic would cut mid-body). +const ( + workflowInstructionsHeading = "## Workflow instructions" + workflowInstructionsEnd = "" +) + func (s *Service) buildWorkflowPromptWithContext( ctx context.Context, basePrompt string, @@ -1453,6 +1463,10 @@ func (s *Service) buildWorkflowPromptWithContext( _ = sessionID var parts []string + if block := s.workflowInstructionsBlock(ctx, step, taskID); block != "" { + parts = append(parts, block) + } + // Build the prompt from step.Prompt template and base prompt if step.Prompt != "" { interpolatedPrompt := sysprompt.InterpolatePlaceholders(step.Prompt, taskID) @@ -1473,6 +1487,40 @@ func (s *Service) buildWorkflowPromptWithContext( return s.expandPromptReferencesWithContext(ctx, joined, isPassthrough) } +// workflowInstructionsBlock returns the visible "## Workflow instructions" +// section when the step's workflow has a non-empty prompt. Empty/whitespace +// prompts and missing getters/workflows omit the section entirely. +func (s *Service) workflowInstructionsBlock(ctx context.Context, step *wfmodels.WorkflowStep, taskID string) string { + if s.workflowStepGetter == nil || step == nil || step.WorkflowID == "" { + return "" + } + prompt, err := s.workflowStepGetter.GetWorkflowPrompt(ctx, step.WorkflowID) + if err != nil { + if s.logger != nil { + s.logger.Warn("failed to get workflow prompt for prompt building", + zap.String("workflow_id", step.WorkflowID), + zap.Error(err)) + } + return "" + } + prompt = strings.TrimSpace(prompt) + if prompt == "" { + return "" + } + interpolated := strings.TrimSpace(sysprompt.InterpolatePlaceholders(prompt, taskID)) + if interpolated == "" { + return "" + } + // Drop any accidental end-marker text from user content so chat split + // cannot cut the block early (frontend also prefers the final marker). + interpolated = strings.ReplaceAll(interpolated, workflowInstructionsEnd, "") + interpolated = strings.TrimSpace(interpolated) + if interpolated == "" { + return "" + } + return workflowInstructionsHeading + "\n\n" + interpolated + "\n\n" + workflowInstructionsEnd +} + // expandPromptReferences resolves "@name" saved-prompt references in prompt // via the configured PromptReferenceExpander. When no expander is set, prompt // is returned unchanged. Passthrough sessions skip expansion entirely: the diff --git a/apps/backend/internal/orchestrator/workflow_prompt_test.go b/apps/backend/internal/orchestrator/workflow_prompt_test.go index aad817b8d6..7a76767290 100644 --- a/apps/backend/internal/orchestrator/workflow_prompt_test.go +++ b/apps/backend/internal/orchestrator/workflow_prompt_test.go @@ -53,6 +53,144 @@ func TestBuildWorkflowPrompt_ReplacesTaskPromptPlaceholder(t *testing.T) { } } +func TestBuildWorkflowPrompt_PrependsWorkflowPromptBeforeStepPrompt(t *testing.T) { + stepGetter := newMockStepGetter() + stepGetter.workflowPrompts["wf-1"] = "If the PR is merged or closed, move the Task to Done." + svc := createTestService(setupTestRepo(t), stepGetter, newMockTaskRepo()) + step := &wfmodels.WorkflowStep{ + ID: "step-1", + WorkflowID: "wf-1", + Prompt: "Commit the changes.", + } + + got := svc.buildWorkflowPrompt(context.Background(), "Migrate Atlantis datasource.", step, "task-1", "session-1", false) + + want := "## Workflow instructions\n\nIf the PR is merged or closed, move the Task to Done.\n\n\n\nCommit the changes." + if got != want { + t.Fatalf("buildWorkflowPrompt() = %q, want %q", got, want) + } +} + +func TestBuildWorkflowPrompt_PrependsWorkflowPromptWithTaskPromptPlaceholder(t *testing.T) { + stepGetter := newMockStepGetter() + stepGetter.workflowPrompts["wf-1"] = "Keep CI green." + svc := createTestService(setupTestRepo(t), stepGetter, newMockTaskRepo()) + step := &wfmodels.WorkflowStep{ + ID: "step-1", + WorkflowID: "wf-1", + Prompt: "Implement this exactly:\n\n{{task_prompt}}", + } + + got := svc.buildWorkflowPrompt(context.Background(), "Migrate Atlantis datasource.", step, "task-1", "session-1", false) + + want := "## Workflow instructions\n\nKeep CI green.\n\n\n\nImplement this exactly:\n\nMigrate Atlantis datasource." + if got != want { + t.Fatalf("buildWorkflowPrompt() = %q, want %q", got, want) + } +} + +func TestBuildWorkflowPrompt_PrependsWorkflowPromptWhenStepPromptEmpty(t *testing.T) { + stepGetter := newMockStepGetter() + stepGetter.workflowPrompts["wf-1"] = "Mention security constraints." + svc := createTestService(setupTestRepo(t), stepGetter, newMockTaskRepo()) + step := &wfmodels.WorkflowStep{ + ID: "step-1", + WorkflowID: "wf-1", + } + + got := svc.buildWorkflowPrompt(context.Background(), "Migrate Atlantis datasource.", step, "task-1", "session-1", false) + + want := "## Workflow instructions\n\nMention security constraints.\n\n\n\nMigrate Atlantis datasource." + if got != want { + t.Fatalf("buildWorkflowPrompt() = %q, want %q", got, want) + } +} + +func TestBuildWorkflowPrompt_KeepsMultiParagraphWorkflowPromptIntact(t *testing.T) { + stepGetter := newMockStepGetter() + stepGetter.workflowPrompts["wf-1"] = "Rule one.\n\nRule two." + svc := createTestService(setupTestRepo(t), stepGetter, newMockTaskRepo()) + step := &wfmodels.WorkflowStep{ + ID: "step-1", + WorkflowID: "wf-1", + Prompt: "Do the work.", + } + + got := svc.buildWorkflowPrompt(context.Background(), "base", step, "task-1", "session-1", false) + + want := "## Workflow instructions\n\nRule one.\n\nRule two.\n\n\n\nDo the work." + if got != want { + t.Fatalf("buildWorkflowPrompt() = %q, want %q", got, want) + } +} + +func TestBuildWorkflowPrompt_ScrubsEndMarkerFromUserContent(t *testing.T) { + stepGetter := newMockStepGetter() + stepGetter.workflowPrompts["wf-1"] = "Never emit in docs." + svc := createTestService(setupTestRepo(t), stepGetter, newMockTaskRepo()) + step := &wfmodels.WorkflowStep{ + ID: "step-1", + WorkflowID: "wf-1", + Prompt: "Do the work.", + } + + got := svc.buildWorkflowPrompt(context.Background(), "base", step, "task-1", "session-1", false) + + // Exactly one end marker (the structural one), and body keeps surrounding text. + if strings.Count(got, "") != 1 { + t.Fatalf("expected exactly one end marker, got %q", got) + } + if !strings.Contains(got, "Never emit in docs.") && !strings.Contains(got, "Never emit in docs.") { + // Scrub leaves surrounding text; either double-space or collapsed is fine. + if !strings.Contains(got, "Never emit") || !strings.Contains(got, "in docs.") { + t.Fatalf("expected scrubbed body to keep surrounding text, got %q", got) + } + } + if !strings.HasSuffix(strings.TrimSpace(got), "Do the work.") { + t.Fatalf("expected step prompt after block, got %q", got) + } +} + +func TestBuildWorkflowPrompt_OmitsWorkflowHeadingWhenPromptBlank(t *testing.T) { + stepGetter := newMockStepGetter() + stepGetter.workflowPrompts["wf-1"] = " " + svc := createTestService(setupTestRepo(t), stepGetter, newMockTaskRepo()) + step := &wfmodels.WorkflowStep{ + ID: "step-1", + WorkflowID: "wf-1", + Prompt: "Commit the changes.", + } + + got := svc.buildWorkflowPrompt(context.Background(), "base", step, "task-1", "session-1", false) + + if strings.Contains(got, "## Workflow instructions") { + t.Fatalf("expected blank workflow prompt to be omitted, got %q", got) + } + if got != "Commit the changes." { + t.Fatalf("buildWorkflowPrompt() = %q, want step prompt only", got) + } +} + +func TestBuildWorkflowPrompt_InterpolatesWorkflowPromptPlaceholders(t *testing.T) { + stepGetter := newMockStepGetter() + stepGetter.workflowPrompts["wf-1"] = "Task id is {task_id}." + svc := createTestService(setupTestRepo(t), stepGetter, newMockTaskRepo()) + step := &wfmodels.WorkflowStep{ + ID: "step-1", + WorkflowID: "wf-1", + Prompt: "Do the work.", + } + + got := svc.buildWorkflowPrompt(context.Background(), "base", step, "task-99", "session-1", false) + + if !strings.Contains(got, "Task id is task-99.") { + t.Fatalf("expected interpolated workflow prompt, got %q", got) + } + if strings.Contains(got, "{task_id}") { + t.Fatalf("expected {task_id} placeholder to be replaced, got %q", got) + } +} + func TestBuildWorkflowPrompt_UsesStepPromptOnlyWithoutTaskPromptPlaceholder(t *testing.T) { svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo()) step := &wfmodels.WorkflowStep{ @@ -102,6 +240,43 @@ func TestBuildWorkflowPrompt_ExpandsStepPromptReference(t *testing.T) { } } +func TestBuildWorkflowPrompt_ExpandsWorkflowPromptReference(t *testing.T) { + // @name in the workflow-level prompt must expand the same way as step prompts: + // expansion runs on the full joined string after instructions are prepended. + stepGetter := newMockStepGetter() + stepGetter.workflowPrompts["wf-1"] = "Follow @security-rules always." + svc := createTestService(setupTestRepo(t), stepGetter, newMockTaskRepo()) + expander := &fakePromptReferenceExpander{} + svc.promptExpander = expander + step := &wfmodels.WorkflowStep{ + ID: "step-1", + WorkflowID: "wf-1", + Prompt: "Commit the changes.", + } + + got := svc.buildWorkflowPrompt(context.Background(), "base", step, "task-1", "session-1", false) + + if len(expander.calls) != 1 { + t.Fatalf("expected expander called once on joined prompt, got %d", len(expander.calls)) + } + seen := expander.calls[0] + if !strings.Contains(seen, "## Workflow instructions") { + t.Fatalf("expected expander to see workflow instructions block, got %q", seen) + } + if !strings.Contains(seen, "Follow @security-rules always.") { + t.Fatalf("expected expander to see workflow @mention, got %q", seen) + } + if !strings.Contains(seen, "Commit the changes.") { + t.Fatalf("expected expander to see step prompt after instructions, got %q", seen) + } + if !strings.Contains(got, "Follow @security-rules always.") { + t.Fatalf("expected visible @mention preserved, got %q", got) + } + if !strings.Contains(got, sysprompt.Wrap(fakeResolvedPromptReferenceContext)) { + t.Fatalf("expected hidden expansion block appended for workflow @mention, got %q", got) + } +} + func TestBuildWorkflowPrompt_PassthroughSkipsReferenceExpansion(t *testing.T) { svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo()) expander := &fakePromptReferenceExpander{} diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 6a09a82db7..99d7c1e893 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -14,6 +14,7 @@ type WorkflowDTO struct { WorkspaceID string `json:"workspace_id"` Name string `json:"name"` Description *string `json:"description,omitempty"` + Prompt *string `json:"prompt,omitempty"` AgentProfileID string `json:"agent_profile_id,omitempty"` SortOrder int `json:"sort_order"` Hidden bool `json:"hidden,omitempty"` @@ -523,12 +524,17 @@ func FromWorkflow(workflow *models.Workflow) WorkflowDTO { if workflow.Description != "" { description = &workflow.Description } + var prompt *string + if workflow.Prompt != "" { + prompt = &workflow.Prompt + } return WorkflowDTO{ ID: workflow.ID, WorkspaceID: workflow.WorkspaceID, Name: workflow.Name, Description: description, + Prompt: prompt, AgentProfileID: workflow.AgentProfileID, SortOrder: workflow.SortOrder, Hidden: workflow.Hidden, diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index 676cdcf685..bcd622e132 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -45,6 +45,7 @@ type CreateWorkflowRequest struct { WorkspaceID string Name string Description string + Prompt string WorkflowTemplateID *string } @@ -52,6 +53,7 @@ type UpdateWorkflowRequest struct { ID string Name *string Description *string + Prompt *string } type DeleteWorkflowRequest struct { diff --git a/apps/backend/internal/task/handlers/workflow_handlers.go b/apps/backend/internal/task/handlers/workflow_handlers.go index 5833136f1c..bb55dc82fb 100644 --- a/apps/backend/internal/task/handlers/workflow_handlers.go +++ b/apps/backend/internal/task/handlers/workflow_handlers.go @@ -154,6 +154,7 @@ type httpCreateWorkflowRequest struct { WorkspaceID string `json:"workspace_id"` Name string `json:"name"` Description string `json:"description,omitempty"` + Prompt string `json:"prompt,omitempty"` WorkflowTemplateID *string `json:"workflow_template_id,omitempty"` } @@ -171,6 +172,7 @@ func (h *WorkflowHandlers) httpCreateWorkflow(c *gin.Context) { WorkspaceID: body.WorkspaceID, Name: body.Name, Description: body.Description, + Prompt: body.Prompt, WorkflowTemplateID: body.WorkflowTemplateID, }) if err != nil { @@ -213,6 +215,7 @@ func (h *WorkflowHandlers) rejectReadOnlyWorkflowHTTP(c *gin.Context, id string) type httpUpdateWorkflowRequest struct { Name *string `json:"name"` Description *string `json:"description"` + Prompt *string `json:"prompt"` AgentProfileID *string `json:"agent_profile_id"` } @@ -229,6 +232,7 @@ func (h *WorkflowHandlers) httpUpdateWorkflow(c *gin.Context) { workflow, err := h.service.UpdateWorkflow(c.Request.Context(), id, &service.UpdateWorkflowRequest{ Name: body.Name, Description: body.Description, + Prompt: body.Prompt, AgentProfileID: body.AgentProfileID, }) if err != nil { @@ -385,6 +389,7 @@ type wsCreateWorkflowRequest struct { WorkspaceID string `json:"workspace_id"` Name string `json:"name"` Description string `json:"description,omitempty"` + Prompt string `json:"prompt,omitempty"` WorkflowTemplateID *string `json:"workflow_template_id,omitempty"` } @@ -404,6 +409,7 @@ func (h *WorkflowHandlers) wsCreateWorkflow(ctx context.Context, msg *ws.Message WorkspaceID: req.WorkspaceID, Name: req.Name, Description: req.Description, + Prompt: req.Prompt, WorkflowTemplateID: req.WorkflowTemplateID, }) if err != nil { @@ -437,6 +443,7 @@ type wsUpdateWorkflowRequest struct { ID string `json:"id"` Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` + Prompt *string `json:"prompt,omitempty"` AgentProfileID *string `json:"agent_profile_id,omitempty"` } @@ -455,6 +462,7 @@ func (h *WorkflowHandlers) wsUpdateWorkflow(ctx context.Context, msg *ws.Message workflow, err := h.service.UpdateWorkflow(ctx, req.ID, &service.UpdateWorkflowRequest{ Name: req.Name, Description: req.Description, + Prompt: req.Prompt, AgentProfileID: req.AgentProfileID, }) if err != nil { diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index cbd5a30340..7abda86948 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -658,8 +658,11 @@ const ( type Workflow struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` - Name string `json:"name"` - Description string `json:"description"` + Name string `json:"name"` + Description string `json:"description"` + // Prompt is optional workflow-level agent instructions prepended at + // every step entry before the step prompt. Empty means off. + Prompt string `json:"prompt,omitempty"` AgentProfileID string `json:"agent_profile_id,omitempty"` WorkflowTemplateID *string `json:"workflow_template_id,omitempty"` SortOrder int `json:"sort_order"` diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index ba4437cc59..2d85694fb9 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -191,6 +191,7 @@ func (r *Repository) runMigrations() error { // file path it was synced from. r.migrate.Apply("workflows.source", `ALTER TABLE workflows ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`) r.migrate.Apply("workflows.source_path", `ALTER TABLE workflows ADD COLUMN source_path TEXT NOT NULL DEFAULT ''`) + r.migrate.Apply("workflows.prompt", `ALTER TABLE workflows ADD COLUMN prompt TEXT NOT NULL DEFAULT ''`) if err := r.ensureImproveKandevWorkflowTemplateUniqueness(); err != nil { return err } diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index 888564d80d..e15db15f9a 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -278,6 +278,7 @@ const infraSchemaDDL = ` workflow_template_id TEXT DEFAULT '', name TEXT NOT NULL, description TEXT DEFAULT '', + prompt TEXT NOT NULL DEFAULT '', hidden INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL diff --git a/apps/backend/internal/task/repository/sqlite/workflow.go b/apps/backend/internal/task/repository/sqlite/workflow.go index 6115b7dcef..535537bfcf 100644 --- a/apps/backend/internal/task/repository/sqlite/workflow.go +++ b/apps/backend/internal/task/repository/sqlite/workflow.go @@ -60,9 +60,9 @@ func (r *Repository) insertWorkflow(ctx context.Context, exec sqlx.ExtContext, w workflow.SortOrder = maxOrder + 1 _, err = exec.ExecContext(ctx, r.db.Rebind(` - INSERT INTO workflows (id, workspace_id, name, description, agent_profile_id, workflow_template_id, sort_order, hidden, style, source, source_path, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `), workflow.ID, workflow.WorkspaceID, workflow.Name, workflow.Description, workflow.AgentProfileID, workflow.WorkflowTemplateID, workflow.SortOrder, dialect.BoolToInt(workflow.Hidden), normalizeWorkflowStyle(workflow.Style), normalizeWorkflowSource(workflow.Source), workflow.SourcePath, workflow.CreatedAt, workflow.UpdatedAt) + INSERT INTO workflows (id, workspace_id, name, description, prompt, agent_profile_id, workflow_template_id, sort_order, hidden, style, source, source_path, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), workflow.ID, workflow.WorkspaceID, workflow.Name, workflow.Description, workflow.Prompt, workflow.AgentProfileID, workflow.WorkflowTemplateID, workflow.SortOrder, dialect.BoolToInt(workflow.Hidden), normalizeWorkflowStyle(workflow.Style), normalizeWorkflowSource(workflow.Source), workflow.SourcePath, workflow.CreatedAt, workflow.UpdatedAt) return err } @@ -89,7 +89,7 @@ func normalizeWorkflowStyle(style string) string { } const workflowSelectColumns = ` - id, workspace_id, name, description, agent_profile_id, + id, workspace_id, name, description, prompt, agent_profile_id, workflow_template_id, sort_order, hidden, style, source, source_path, created_at, updated_at ` @@ -106,6 +106,7 @@ func scanWorkflowRow(scanner workflowScanner) (*models.Workflow, error) { &workflow.WorkspaceID, &workflow.Name, &workflow.Description, + &workflow.Prompt, &agentProfileID, &workflowTemplateID, &workflow.SortOrder, @@ -169,8 +170,8 @@ func (r *Repository) UpdateWorkflow(ctx context.Context, workflow *models.Workfl workflow.UpdatedAt = time.Now().UTC() result, err := r.db.ExecContext(ctx, r.db.Rebind(` - UPDATE workflows SET name = ?, description = ?, agent_profile_id = ?, workflow_template_id = ?, hidden = ?, style = ?, source = ?, source_path = ?, updated_at = ? WHERE id = ? - `), workflow.Name, workflow.Description, workflow.AgentProfileID, workflow.WorkflowTemplateID, dialect.BoolToInt(workflow.Hidden), normalizeWorkflowStyle(workflow.Style), normalizeWorkflowSource(workflow.Source), workflow.SourcePath, workflow.UpdatedAt, workflow.ID) + UPDATE workflows SET name = ?, description = ?, prompt = ?, agent_profile_id = ?, workflow_template_id = ?, hidden = ?, style = ?, source = ?, source_path = ?, updated_at = ? WHERE id = ? + `), workflow.Name, workflow.Description, workflow.Prompt, workflow.AgentProfileID, workflow.WorkflowTemplateID, dialect.BoolToInt(workflow.Hidden), normalizeWorkflowStyle(workflow.Style), normalizeWorkflowSource(workflow.Source), workflow.SourcePath, workflow.UpdatedAt, workflow.ID) if err != nil { return err } diff --git a/apps/backend/internal/task/repository/workflow_repository_test.go b/apps/backend/internal/task/repository/workflow_repository_test.go index 964a6e0f19..29ab8742c7 100644 --- a/apps/backend/internal/task/repository/workflow_repository_test.go +++ b/apps/backend/internal/task/repository/workflow_repository_test.go @@ -60,6 +60,80 @@ func TestSQLiteRepository_WorkflowCRUD(t *testing.T) { } } +func TestSQLiteRepository_WorkflowPromptRoundTrip(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + t.Cleanup(cleanup) + ctx := context.Background() + + workspace := &models.Workspace{ID: "ws-prompt", Name: "Workspace"} + if err := repo.CreateWorkspace(ctx, workspace); err != nil { + t.Fatalf("failed to create workspace: %v", err) + } + + workflow := &models.Workflow{ + WorkspaceID: workspace.ID, + Name: "Prompted Workflow", + Description: "desc", + Prompt: "If the PR is merged or closed, move the Task to Done.", + } + if err := repo.CreateWorkflow(ctx, workflow); err != nil { + t.Fatalf("failed to create workflow: %v", err) + } + + retrieved, err := repo.GetWorkflow(ctx, workflow.ID) + if err != nil { + t.Fatalf("failed to get workflow: %v", err) + } + if retrieved.Prompt != workflow.Prompt { + t.Fatalf("expected prompt %q, got %q", workflow.Prompt, retrieved.Prompt) + } + + workflow.Prompt = "Updated shared workflow instructions." + if err := repo.UpdateWorkflow(ctx, workflow); err != nil { + t.Fatalf("failed to update workflow prompt: %v", err) + } + retrieved, err = repo.GetWorkflow(ctx, workflow.ID) + if err != nil { + t.Fatalf("failed to get workflow after update: %v", err) + } + if retrieved.Prompt != "Updated shared workflow instructions." { + t.Fatalf("expected updated prompt, got %q", retrieved.Prompt) + } + + // Clearing the prompt must persist as empty, not leave the previous value. + workflow.Prompt = "" + if err := repo.UpdateWorkflow(ctx, workflow); err != nil { + t.Fatalf("failed to clear workflow prompt: %v", err) + } + retrieved, err = repo.GetWorkflow(ctx, workflow.ID) + if err != nil { + t.Fatalf("failed to get workflow after clear: %v", err) + } + if retrieved.Prompt != "" { + t.Fatalf("expected empty prompt after clear, got %q", retrieved.Prompt) + } + + // Updating an unrelated field must not wipe a stored prompt. + workflow.Prompt = "Keep me" + if err := repo.UpdateWorkflow(ctx, workflow); err != nil { + t.Fatalf("failed to set prompt before name-only update: %v", err) + } + workflow.Name = "Renamed" + if err := repo.UpdateWorkflow(ctx, workflow); err != nil { + t.Fatalf("failed to update workflow name: %v", err) + } + retrieved, err = repo.GetWorkflow(ctx, workflow.ID) + if err != nil { + t.Fatalf("failed to get workflow after name update: %v", err) + } + if retrieved.Name != "Renamed" { + t.Fatalf("expected renamed workflow, got %q", retrieved.Name) + } + if retrieved.Prompt != "Keep me" { + t.Fatalf("expected prompt to survive name update, got %q", retrieved.Prompt) + } +} + func TestSQLiteRepository_WorkflowNotFound(t *testing.T) { repo, cleanup := createTestSQLiteRepo(t) defer cleanup() diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 260d83f2b7..98f94fdc8f 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -89,6 +89,7 @@ type CreateWorkflowRequest struct { WorkspaceID string `json:"workspace_id"` Name string `json:"name"` Description string `json:"description"` + Prompt string `json:"prompt,omitempty"` WorkflowTemplateID *string `json:"workflow_template_id,omitempty"` // Hidden marks the workflow as system-only; excluded from management UI and pickers. Hidden bool `json:"hidden,omitempty"` @@ -98,6 +99,7 @@ type CreateWorkflowRequest struct { type UpdateWorkflowRequest struct { Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` + Prompt *string `json:"prompt,omitempty"` AgentProfileID *string `json:"agent_profile_id,omitempty"` } diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index 3818bf0268..2b56a83d70 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -504,6 +504,7 @@ func (s *Service) CreateWorkflow(ctx context.Context, req *CreateWorkflowRequest WorkspaceID: req.WorkspaceID, Name: req.Name, Description: req.Description, + Prompt: req.Prompt, WorkflowTemplateID: req.WorkflowTemplateID, Hidden: req.Hidden, } @@ -553,6 +554,9 @@ func (s *Service) UpdateWorkflow(ctx context.Context, id string, req *UpdateWork if req.Description != nil { workflow.Description = *req.Description } + if req.Prompt != nil { + workflow.Prompt = *req.Prompt + } if req.AgentProfileID != nil { workflow.AgentProfileID = strings.TrimSpace(*req.AgentProfileID) } diff --git a/apps/backend/internal/workflow/models/export.go b/apps/backend/internal/workflow/models/export.go index aa9bd07e68..a9e0f27a99 100644 --- a/apps/backend/internal/workflow/models/export.go +++ b/apps/backend/internal/workflow/models/export.go @@ -36,6 +36,7 @@ type AgentProfileMatcher func(agentName, model, mode string) string type WorkflowPortable struct { Name string `json:"name" yaml:"name"` Description string `json:"description,omitempty" yaml:"description,omitempty"` + Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` AgentProfile *AgentProfilePortable `json:"agent_profile,omitempty" yaml:"agent_profile,omitempty"` Steps []StepPortable `json:"steps" yaml:"steps"` } @@ -108,6 +109,7 @@ func buildWorkflowPortable(wf *taskmodels.Workflow, steps []*WorkflowStep, resol wp := WorkflowPortable{ Name: wf.Name, Description: wf.Description, + Prompt: wf.Prompt, Steps: portableSteps, } if resolveProfile != nil && wf.AgentProfileID != "" { diff --git a/apps/backend/internal/workflow/models/export_test.go b/apps/backend/internal/workflow/models/export_test.go index 777c59312c..fffb215b77 100644 --- a/apps/backend/internal/workflow/models/export_test.go +++ b/apps/backend/internal/workflow/models/export_test.go @@ -14,7 +14,10 @@ import ( func TestBuildWorkflowExport(t *testing.T) { t.Run("converts step IDs to positions", func(t *testing.T) { - wf := &taskmodels.Workflow{ID: "wf-1", Name: "My Workflow", Description: "desc"} + wf := &taskmodels.Workflow{ + ID: "wf-1", Name: "My Workflow", Description: "desc", + Prompt: "If the PR is merged or closed, move the Task to Done.", + } steps := []*WorkflowStep{ {ID: "step-a", Name: "Todo", Position: 0, Color: "blue"}, { @@ -37,6 +40,7 @@ func TestBuildWorkflowExport(t *testing.T) { pw := export.Workflows[0] assert.Equal(t, "My Workflow", pw.Name) assert.Equal(t, "desc", pw.Description) + assert.Equal(t, "If the PR is merged or closed, move the Task to Done.", pw.Prompt) require.Len(t, pw.Steps, 2) // The move_to_step should reference position 0 (step-a's position), not step-a ID. diff --git a/apps/backend/internal/workflow/service/service.go b/apps/backend/internal/workflow/service/service.go index cf15711bf5..765ec19bc6 100644 --- a/apps/backend/internal/workflow/service/service.go +++ b/apps/backend/internal/workflow/service/service.go @@ -165,6 +165,16 @@ func (s *Service) GetWorkflowAgentProfileID(ctx context.Context, workflowID stri return wf.AgentProfileID, nil } +// GetWorkflowPrompt returns the optional workflow-level agent instructions. +// Empty string means the workflow has no prompt configured. +func (s *Service) GetWorkflowPrompt(ctx context.Context, workflowID string) (string, error) { + wf, err := s.workflowProvider.GetWorkflow(ctx, workflowID) + if err != nil { + return "", err + } + return wf.Prompt, nil +} + // GetPreviousStepByPosition returns the previous step before the given position for a workflow. // Returns nil if there is no previous step (i.e., current step is the first one). func (s *Service) GetPreviousStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*models.WorkflowStep, error) { @@ -554,13 +564,21 @@ func (s *Service) importSingleWorkflow(ctx context.Context, workspaceID string, return nil, fmt.Errorf("create workflow: %w", err) } + needsUpdate := false // Match workflow-level agent profile if present. if pw.AgentProfile != nil && s.matchProfile != nil { if profileID := s.matchProfile(pw.AgentProfile.AgentName, pw.AgentProfile.Model, pw.AgentProfile.Mode); profileID != "" { wf.AgentProfileID = profileID - if err := s.workflowProvider.UpdateWorkflow(ctx, wf); err != nil { - return nil, fmt.Errorf("set workflow agent profile: %w", err) - } + needsUpdate = true + } + } + if pw.Prompt != "" { + wf.Prompt = pw.Prompt + needsUpdate = true + } + if needsUpdate { + if err := s.workflowProvider.UpdateWorkflow(ctx, wf); err != nil { + return nil, fmt.Errorf("set workflow fields: %w", err) } } diff --git a/apps/backend/internal/workflow/service/service_test.go b/apps/backend/internal/workflow/service/service_test.go index b2ef4653d5..33881a9616 100644 --- a/apps/backend/internal/workflow/service/service_test.go +++ b/apps/backend/internal/workflow/service/service_test.go @@ -713,6 +713,33 @@ func TestImportWorkflows(t *testing.T) { assert.Len(t, steps, 2) }) + t.Run("imports workflow-level prompt", func(t *testing.T) { + svc, _, provider := setupTestServiceWithProvider(t) + ctx := context.Background() + + export := &models.WorkflowExport{ + Version: models.ExportVersion, + Type: models.ExportType, + Workflows: []models.WorkflowPortable{ + { + Name: "Prompted WF", + Prompt: "If the PR is merged or closed, move the Task to Done.", + Steps: []models.StepPortable{ + {Name: "Todo", Position: 0, Color: "gray"}, + }, + }, + }, + } + + result, err := svc.ImportWorkflows(ctx, "ws-1", export) + require.NoError(t, err) + assert.Equal(t, []string{"Prompted WF"}, result.Created) + + wf, err := provider.GetWorkflow(ctx, "imported-Prompted WF") + require.NoError(t, err) + assert.Equal(t, "If the PR is merged or closed, move the Task to Done.", wf.Prompt) + }) + t.Run("normalizes duplicate start steps on import", func(t *testing.T) { svc, _, _ := setupTestServiceWithProvider(t) ctx := context.Background() diff --git a/apps/backend/internal/workflow/service/sync_apply.go b/apps/backend/internal/workflow/service/sync_apply.go index f00a9f61ef..a38d6ed3d4 100644 --- a/apps/backend/internal/workflow/service/sync_apply.go +++ b/apps/backend/internal/workflow/service/sync_apply.go @@ -270,10 +270,11 @@ func (s *Service) applyWorkflowFields(ctx context.Context, wf *taskmodels.Workfl if pw.AgentProfile != nil && s.matchProfile != nil { profileID = s.matchProfile(pw.AgentProfile.AgentName, pw.AgentProfile.Model, pw.AgentProfile.Mode) } - if wf.Description == pw.Description && wf.AgentProfileID == profileID { + if wf.Description == pw.Description && wf.Prompt == pw.Prompt && wf.AgentProfileID == profileID { return false, nil } wf.Description = pw.Description + wf.Prompt = pw.Prompt wf.AgentProfileID = profileID return true, s.workflowProvider.UpdateWorkflow(ctx, wf) } diff --git a/apps/backend/internal/workflow/service/sync_apply_test.go b/apps/backend/internal/workflow/service/sync_apply_test.go index c2abd24497..69be8e5f7d 100644 --- a/apps/backend/internal/workflow/service/sync_apply_test.go +++ b/apps/backend/internal/workflow/service/sync_apply_test.go @@ -118,6 +118,40 @@ func TestApplySyncedWorkflows_CreatesNewWorkflows(t *testing.T) { 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() diff --git a/apps/backend/pkg/api/v1/workflow.go b/apps/backend/pkg/api/v1/workflow.go index 7466322642..da379b603c 100644 --- a/apps/backend/pkg/api/v1/workflow.go +++ b/apps/backend/pkg/api/v1/workflow.go @@ -8,6 +8,7 @@ type Workflow struct { WorkspaceID string `json:"workspace_id"` Name string `json:"name"` Description *string `json:"description,omitempty"` + Prompt *string `json:"prompt,omitempty"` SortOrder int `json:"sort_order"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -17,10 +18,12 @@ type Workflow struct { type CreateWorkflowRequest struct { Name string `json:"name" binding:"required,max=255"` Description *string `json:"description,omitempty"` + Prompt *string `json:"prompt,omitempty"` } // UpdateWorkflowRequest for updating a workflow type UpdateWorkflowRequest struct { Name *string `json:"name,omitempty" binding:"omitempty,max=255"` Description *string `json:"description,omitempty"` + Prompt *string `json:"prompt,omitempty"` } diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index fb9738f007..330a2f5925 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -138,6 +138,7 @@ export async function createWorkflowAction(payload: { workspace_id: string; name: string; description?: string; + prompt?: string; workflow_template_id?: string; }) { return fetchJson(`${apiBaseUrl}/api/v1/workflows`, { @@ -148,7 +149,7 @@ export async function createWorkflowAction(payload: { export async function updateWorkflowAction( id: string, - payload: { name?: string; description?: string; agent_profile_id?: string }, + payload: { name?: string; description?: string; prompt?: string; agent_profile_id?: string }, ) { return fetchJson(`${apiBaseUrl}/api/v1/workflows/${id}`, { method: "PATCH", diff --git a/apps/web/app/settings/workspace/workspace-workflows-client.tsx b/apps/web/app/settings/workspace/workspace-workflows-client.tsx index de8b474a40..1472a6d071 100644 --- a/apps/web/app/settings/workspace/workspace-workflows-client.tsx +++ b/apps/web/app/settings/workspace/workspace-workflows-client.tsx @@ -167,6 +167,7 @@ function hasNewerWorkflowMetadata(current: Workflow, savedFrom: Workflow) { return ( current.name !== savedFrom.name || current.description !== savedFrom.description || + (current.prompt ?? "") !== (savedFrom.prompt ?? "") || current.agent_profile_id !== savedFrom.agent_profile_id ); } @@ -193,7 +194,7 @@ function useWorkflowActions({ const handleUpdateWorkflow = ( workflowId: string, - updates: { name?: string; description?: string; agent_profile_id?: string }, + updates: { name?: string; description?: string; prompt?: string; agent_profile_id?: string }, ) => { setWorkflowItems((prev) => prev.map((wf) => diff --git a/apps/web/components/settings/use-workflow-draft-contributor.ts b/apps/web/components/settings/use-workflow-draft-contributor.ts index 2a00f8290b..41e62b4d7f 100644 --- a/apps/web/components/settings/use-workflow-draft-contributor.ts +++ b/apps/web/components/settings/use-workflow-draft-contributor.ts @@ -47,7 +47,7 @@ function useWorkflowDraftPersistence(args: WorkflowDraftContributorArgs) { const saveProgressRef = useRef(createWorkflowDraftSaveProgress()); const saveFailedRef = useRef(false); const revision = JSON.stringify({ - workflow: [workflow.name, workflow.description, workflow.agent_profile_id], + workflow: [workflow.name, workflow.description, workflow.prompt, workflow.agent_profile_id], steps: workflowSteps, }); const latestRevisionRef = useRef(revision); diff --git a/apps/web/components/settings/workflow-card-actions.ts b/apps/web/components/settings/workflow-card-actions.ts index 996345a781..062c4971d2 100644 --- a/apps/web/components/settings/workflow-card-actions.ts +++ b/apps/web/components/settings/workflow-card-actions.ts @@ -246,6 +246,7 @@ export async function persistWorkflowDraft({ const updatedWorkflow = await updateWorkflowAction(persistedWorkflow.id, { name: workflow.name.trim(), description: workflow.description ?? "", + prompt: workflow.prompt ?? "", agent_profile_id: workflow.agent_profile_id ?? "", }); progress.workflow = updatedWorkflow; @@ -272,6 +273,7 @@ async function ensurePersistedWorkflow( workspace_id: workflow.workspace_id, name: workflow.name.trim(), description: workflow.description ?? undefined, + prompt: workflow.prompt ?? undefined, workflow_template_id: workflow.workflow_template_id ?? undefined, }) : workflow; diff --git a/apps/web/components/settings/workflow-card.tsx b/apps/web/components/settings/workflow-card.tsx index cc7519bcd1..78238fa680 100644 --- a/apps/web/components/settings/workflow-card.tsx +++ b/apps/web/components/settings/workflow-card.tsx @@ -29,6 +29,7 @@ import { WorkflowSyncedBadge } from "./workflow-synced-badge"; import { useWorkflowMutationGuard } from "./workflow-mutation-guard"; import { WorkflowCycleGuardDialog } from "./workflow-cycle-diagnostic"; import { useWorkflowDraftContributor } from "./use-workflow-draft-contributor"; +import { WorkflowPromptSection } from "./workflow-prompt-section"; const TEMP_WORKFLOW_PREFIX = "temp-workflow-"; @@ -42,6 +43,7 @@ type WorkflowCardProps = { onUpdateWorkflow: (updates: { name?: string; description?: string; + prompt?: string; agent_profile_id?: string; }) => void; onDeleteWorkflow: () => Promise; @@ -218,6 +220,7 @@ type WorkflowCardBodyProps = { onUpdateWorkflow: (updates: { name?: string; description?: string; + prompt?: string; agent_profile_id?: string; }) => void; workflowLoading: boolean; @@ -306,6 +309,12 @@ function WorkflowCardBody({ + onUpdateWorkflow({ prompt })} + />
{workflowLoading ? ( diff --git a/apps/web/components/settings/workflow-dirty-state.test.ts b/apps/web/components/settings/workflow-dirty-state.test.ts index e7acd49ec6..f84e3f6e45 100644 --- a/apps/web/components/settings/workflow-dirty-state.test.ts +++ b/apps/web/components/settings/workflow-dirty-state.test.ts @@ -36,6 +36,14 @@ describe("workflow dirty state", () => { expect(isWorkflowFieldDirty(draft, workflow, "name")).toBe(true); expect(isWorkflowFieldDirty(draft, workflow, "agent_profile_id")).toBe(false); + expect(isWorkflowFieldDirty(draft, workflow, "prompt")).toBe(false); + }); + + it("marks a changed workflow prompt dirty", () => { + const draft = { ...workflow, prompt: "If the PR is merged or closed, move the Task to Done." }; + expect(isWorkflowFieldDirty(draft, workflow, "prompt")).toBe(true); + expect(isWorkflowFieldDirty(draft, { ...workflow, prompt: "" }, "prompt")).toBe(true); + expect(isWorkflowFieldDirty(draft, draft, "prompt")).toBe(false); }); it("marks new and changed steps dirty", () => { diff --git a/apps/web/components/settings/workflow-dirty-state.ts b/apps/web/components/settings/workflow-dirty-state.ts index 12342c6ac7..af1ff3ce73 100644 --- a/apps/web/components/settings/workflow-dirty-state.ts +++ b/apps/web/components/settings/workflow-dirty-state.ts @@ -9,7 +9,7 @@ function valuesEqual(left: unknown, right: unknown): boolean { export function isWorkflowFieldDirty( draft: Workflow, saved: Workflow | undefined, - field: "name" | "description" | "agent_profile_id", + field: "name" | "description" | "prompt" | "agent_profile_id", ): boolean { if (!saved) return true; return !valuesEqual(draft[field] ?? "", saved[field] ?? ""); diff --git a/apps/web/components/settings/workflow-prompt-section.test.tsx b/apps/web/components/settings/workflow-prompt-section.test.tsx new file mode 100644 index 0000000000..3e3ae0fca0 --- /dev/null +++ b/apps/web/components/settings/workflow-prompt-section.test.tsx @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { Workflow } from "@/lib/types/http"; +import { WorkflowPromptSection } from "./workflow-prompt-section"; + +const PROMPT_INPUT = "workflow-prompt-input"; + +const baseWorkflow = { + id: "workflow-1", + workspace_id: "workspace-1", + name: "Workflow", + description: "", + created_at: "", + updated_at: "", +} as Workflow; + +afterEach(() => { + cleanup(); +}); + +describe("WorkflowPromptSection", () => { + it("starts collapsed when the prompt is empty", () => { + render(); + + expect(screen.getByTestId("workflow-prompt-toggle")).toBeTruthy(); + expect(screen.queryByTestId(PROMPT_INPUT)).toBeNull(); + expect(screen.getByText(/Optional/i)).toBeTruthy(); + }); + + it("starts expanded when a non-empty prompt is loaded", () => { + const workflow = { + ...baseWorkflow, + prompt: "If the PR is merged or closed, move the Task to Done.", + }; + render(); + + const input = screen.getByTestId(PROMPT_INPUT) as HTMLTextAreaElement; + expect(input.value).toBe(workflow.prompt); + }); + + it("auto-expands when an empty prompt becomes non-empty", () => { + const onUpdate = vi.fn(); + const { rerender } = render( + , + ); + expect(screen.queryByTestId(PROMPT_INPUT)).toBeNull(); + + rerender( + , + ); + expect(screen.getByTestId(PROMPT_INPUT)).toBeTruthy(); + }); + + it("toggles open via the header control", () => { + render(); + fireEvent.click(screen.getByTestId("workflow-prompt-toggle")); + expect(screen.getByTestId(PROMPT_INPUT)).toBeTruthy(); + }); + + it("marks the textarea dirty when the draft prompt differs from saved", () => { + const draft = { ...baseWorkflow, prompt: "new" }; + const saved = { ...baseWorkflow, prompt: "old" }; + render(); + const input = screen.getByTestId(PROMPT_INPUT); + expect(input.getAttribute("data-settings-dirty")).toBe("true"); + }); +}); diff --git a/apps/web/components/settings/workflow-prompt-section.tsx b/apps/web/components/settings/workflow-prompt-section.tsx new file mode 100644 index 0000000000..5850733db5 --- /dev/null +++ b/apps/web/components/settings/workflow-prompt-section.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconChevronRight } from "@tabler/icons-react"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible"; +import { Label } from "@kandev/ui/label"; +import { Textarea } from "@kandev/ui/textarea"; +import { cn } from "@/lib/utils"; +import type { Workflow } from "@/lib/types/http"; +import { HelpTip } from "./workflow-pipeline-editor-helpers"; +import { isWorkflowFieldDirty } from "./workflow-dirty-state"; + +type WorkflowPromptSectionProps = { + workflow: Workflow; + savedWorkflow?: Workflow; + readOnly?: boolean; + onUpdate: (prompt: string) => void; +}; + +/** + * Optional workflow-level agent prompt editor. + * Collapsed when empty so the common "no shared instructions" case stays compact; + * opens automatically when a non-empty prompt is loaded or the user expands it. + */ +export function WorkflowPromptSection({ + workflow, + savedWorkflow, + readOnly, + onUpdate, +}: WorkflowPromptSectionProps) { + const { t } = useTranslation(); + const prompt = workflow.prompt ?? ""; + const hasPrompt = prompt.trim().length > 0; + const [open, setOpen] = useState(hasPrompt); + + // Keep expanded when content arrives (e.g. load/import/dirty restore). + useEffect(() => { + if (hasPrompt) setOpen(true); + }, [hasPrompt]); + + return ( + +
+ + + + + + {!open && !hasPrompt && ( + + {t("workflows:workflowPromptCollapsedHint")} + + )} + {!open && hasPrompt && ( + + {t("workflows:workflowPromptSetHint")} + + )} +
+ +