From 004a7a55dbd638abca3cb2abe5054172b6f922cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 10 Sep 2026 16:32:41 +0100 Subject: [PATCH] feat(workflows): add explicit session targeting (#3557) --- .agents/skills/fix/SKILL.md | 5 + .agents/skills/mobile-parity/SKILL.md | 6 + .agents/skills/plan/SKILL.md | 18 +- .../skills/spec-driven-development/SKILL.md | 6 + .agents/skills/tdd/SKILL.md | 6 + apps/backend/config/workflows/loader.go | 7 + .../internal/backendapp/boot_state_routes.go | 1 + .../mcp/handlers/config_workflow_handlers.go | 72 +- .../handlers/config_workflow_handlers_test.go | 2 +- .../internal/mcp/server/config_handlers.go | 36 +- .../mcp/server/config_handlers_test.go | 38 + ...vent_handlers_initial_prompt_dedup_test.go | 33 + .../event_handlers_pending_move_test.go | 6 +- .../orchestrator/event_handlers_workflow.go | 135 +++- ...dlers_workflow_passthrough_profile_test.go | 1 + ..._profile_session_policy_resolution_test.go | 21 + .../event_handlers_workflow_profile_test.go | 15 +- ...ent_handlers_workflow_prompt_claim_test.go | 2 + .../event_handlers_workflow_test.go | 8 +- .../orchestrator/executor/executor.go | 20 + .../orchestrator/executor/executor_execute.go | 62 +- .../internal/orchestrator/task_operations.go | 194 ++++- .../orchestrator/task_operations_test.go | 39 + .../orchestrator/workflow_session_config.go | 23 +- .../orchestrator/workflow_session_target.go | 543 +++++++++++++ .../workflow_session_target_test.go | 389 +++++++++ .../persistence/requiredstores/catalog.go | 2 +- .../testdata/upgrades/v0.93.0/manifest.json | 3 +- .../storeconformance/upgrade_test.go | 11 +- apps/backend/internal/task/dto/converters.go | 1 + apps/backend/internal/task/dto/dto.go | 2 + .../workflow_profile_session_policy_test.go | 4 +- apps/backend/internal/task/models/models.go | 89 ++- .../workflow_profile_session_policy_test.go | 4 +- .../task/repository/sqlite/base_migrations.go | 6 +- .../task/repository/sqlite/base_schema.go | 9 +- .../task/repository/sqlite/defaults.go | 10 +- .../sqlite/repository_entity_test.go | 12 + .../task/repository/sqlite/session.go | 309 ++++++- .../task/repository/sqlite/session_test.go | 43 + .../repository/sqlite/step_transitions.go | 22 + .../sqlite/step_transitions_test.go | 39 + .../internal/task/repository/sqlite/task.go | 30 + .../task/repository/sqlite/workflow.go | 50 +- .../sqlite/workflow_session_bindings.go | 129 +++ .../sqlite/workflow_session_bindings_test.go | 163 ++++ .../repository/sqlite/workspace_bootstrap.go | 10 +- .../workflow/controller/controller.go | 136 ++++ .../workflow/controller/session_target.go | 33 + .../controller/session_target_test.go | 36 + .../workflow/handlers/http_handlers_test.go | 2 +- .../internal/workflow/models/export.go | 150 +++- .../internal/workflow/models/export_test.go | 33 +- .../internal/workflow/models/models.go | 2 + .../workflow/models/session_config.go | 14 +- .../workflow/models/session_target.go | 70 ++ .../repository/profile_session_policy_test.go | 22 +- .../internal/workflow/repository/sqlite.go | 62 +- .../internal/workflow/service/service.go | 40 +- .../internal/workflow/service/service_test.go | 2 +- .../internal/workflow/service/sync_apply.go | 7 + .../workflow_profile_session_policy_test.go | 4 +- .../workflow/stepevents/stepevents.go | 1 + .../workflow/stepevents/stepevents_test.go | 1 + apps/web/app/actions/workspaces.ts | 6 + .../workspace/workflow-duplication.test.ts | 2 + .../workspace/workflow-duplication.ts | 4 + .../workspace/workspace-workflows-client.tsx | 45 +- .../use-workflow-draft-contributor.ts | 16 +- ...w-card-actions-dependency-ordering.test.ts | 164 ++++ .../settings/workflow-card-actions.ts | 90 ++- .../settings/workflow-card-header-actions.tsx | 2 +- .../settings/workflow-export-actions.ts | 31 + .../workflow-pipeline-editor-panels.tsx | 15 + .../settings/workflow-pipeline-editor.tsx | 110 ++- .../workflow-session-target-surfaces.tsx | 500 ++++++++++++ ...workflow-session-target-validation.test.ts | 83 ++ .../workflow-session-target-validation.ts | 37 + ...kflow-step-agent-profile-selector.test.tsx | 164 +++- .../workflow-step-agent-profile-selector.tsx | 756 +++++++++--------- .../settings/workflow-step-draft-equality.ts | 46 ++ .../settings/workflow-step-equality.ts | 1 + .../task/mobile/mobile-picker-sheet.tsx | 4 + apps/web/e2e/helpers/api-client.ts | 7 + .../e2e/tests/terminal/terminal-agent.spec.ts | 4 +- .../mobile-workflow-session-targeting.spec.ts | 101 +++ .../workflow/mobile-workflow-settings.spec.ts | 32 + .../workflow-session-targeting.spec.ts | 298 +++++++ .../tests/workflow/workflow-sorting.spec.ts | 22 +- .../workflow-step-autocomplete.spec.ts | 24 +- apps/web/lib/api/domains/workflow-api.test.ts | 2 +- apps/web/lib/api/domains/workflow-api.ts | 3 + apps/web/lib/types/http.ts | 8 +- apps/web/lib/ws/handlers/workflows.ts | 2 +- apps/web/src/locales/en/workflows.json | 20 + apps/web/src/locales/pseudo/workflows.json | 20 + apps/web/src/locales/pt-pt/workflows.json | 20 + apps/web/src/locales/zh-cn/workflows.json | 20 + apps/web/src/locales/zh-hk/workflows.json | 20 + apps/web/src/locales/zh-tw/workflows.json | 20 + ...-workflow-profile-session-switch-policy.md | 34 +- docs/plans/workflow-session-targeting/plan.md | 362 +++++++++ .../task-01-persist-recipient-contracts.md | 129 +++ .../task-02-route-explicit-recipients.md | 116 +++ .../task-03-expose-session-choices.md | 241 ++++++ .../task-04-prove-session-targeting.md | 123 +++ .../task-05-persist-source-step-targets.md | 118 +++ .../task-06-route-source-step-recipients.md | 109 +++ .../task-07-expose-source-step-choices.md | 184 +++++ .../task-08-prove-source-step-targeting.md | 115 +++ docs/public/tasks-and-workflows.md | 31 +- docs/public/workflow-import-export.md | 14 + docs/public/workflow-sync.md | 8 +- docs/public/workflow-tips.md | 3 +- docs/specs/guide/plans-and-work-orders.md | 43 +- docs/specs/tasks/README.md | 4 +- .../workflow-profile-session-lifecycle.md | 111 ++- .../workflow-profile-session-lifecycle.md | 298 ++++++- .../workflow-step-fixed-profile-routing.md | 7 + 119 files changed, 7289 insertions(+), 711 deletions(-) create mode 100644 apps/backend/internal/orchestrator/workflow_session_target.go create mode 100644 apps/backend/internal/orchestrator/workflow_session_target_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/workflow_session_bindings.go create mode 100644 apps/backend/internal/task/repository/sqlite/workflow_session_bindings_test.go create mode 100644 apps/backend/internal/workflow/controller/session_target.go create mode 100644 apps/backend/internal/workflow/controller/session_target_test.go create mode 100644 apps/backend/internal/workflow/models/session_target.go create mode 100644 apps/web/components/settings/workflow-card-actions-dependency-ordering.test.ts create mode 100644 apps/web/components/settings/workflow-export-actions.ts create mode 100644 apps/web/components/settings/workflow-session-target-surfaces.tsx create mode 100644 apps/web/components/settings/workflow-session-target-validation.test.ts create mode 100644 apps/web/components/settings/workflow-session-target-validation.ts create mode 100644 apps/web/components/settings/workflow-step-draft-equality.ts create mode 100644 apps/web/e2e/tests/workflow/mobile-workflow-session-targeting.spec.ts create mode 100644 apps/web/e2e/tests/workflow/workflow-session-targeting.spec.ts create mode 100644 docs/plans/workflow-session-targeting/plan.md create mode 100644 docs/plans/workflow-session-targeting/task-01-persist-recipient-contracts.md create mode 100644 docs/plans/workflow-session-targeting/task-02-route-explicit-recipients.md create mode 100644 docs/plans/workflow-session-targeting/task-03-expose-session-choices.md create mode 100644 docs/plans/workflow-session-targeting/task-04-prove-session-targeting.md create mode 100644 docs/plans/workflow-session-targeting/task-05-persist-source-step-targets.md create mode 100644 docs/plans/workflow-session-targeting/task-06-route-source-step-recipients.md create mode 100644 docs/plans/workflow-session-targeting/task-07-expose-source-step-choices.md create mode 100644 docs/plans/workflow-session-targeting/task-08-prove-source-step-targeting.md diff --git a/.agents/skills/fix/SKILL.md b/.agents/skills/fix/SKILL.md index a8796ac502..0fb8e7c85f 100644 --- a/.agents/skills/fix/SKILL.md +++ b/.agents/skills/fix/SKILL.md @@ -83,6 +83,9 @@ The package must: - Name the regression test that fails before the correction. - Name exact files, dependencies, acceptance conditions, and commands. - Use dependency waves only when they clarify implementation order. +- For rendered UI changes, include ASCII previews in the plan and relevant UI + work orders per `docs/specs/guide/plans-and-work-orders.md#ascii-ui-previews`. + Show before/after when it explains the defect and correction. Keep work orders sequential by default. A wave does not authorize delegation. @@ -95,6 +98,8 @@ Before you change production or permanent test code, report: - Plan and work-order paths. - Dependency order and exact commands. - Risks and exclusions. +- For UI changes, a compact inline ASCII preview of the correction, including + phone composition when different, and links to the full plan/work-order views. Then end the turn. Do not ask the user to approve the package or switch models. Wait for a later explicit implementation request. diff --git a/.agents/skills/mobile-parity/SKILL.md b/.agents/skills/mobile-parity/SKILL.md index d629f616e2..068930514b 100644 --- a/.agents/skills/mobile-parity/SKILL.md +++ b/.agents/skills/mobile-parity/SKILL.md @@ -46,6 +46,12 @@ When a task changes composition, navigation, overlays, touch behavior, scrolling - shared state, view-model, filtering/selection, and business logic versus mobile-specific presentation - mobile Playwright scenario proving the same user value +For a feature or fix design package, record the desktop and phone composition +as ASCII previews in the plan and relevant UI work orders. Follow +`docs/specs/guide/plans-and-work-orders.md#ascii-ui-previews`; include a compact +preview in the final conversation handoff. During implementation, compare the +rendered phone surface with the assigned preview's structure and annotations. + ## Workflow 1. Map affected surfaces. diff --git a/.agents/skills/plan/SKILL.md b/.agents/skills/plan/SKILL.md index 2046b3502e..d2a9ab03c9 100644 --- a/.agents/skills/plan/SKILL.md +++ b/.agents/skills/plan/SKILL.md @@ -92,6 +92,13 @@ State the result, the implementation order, and the reason for that order. Name exact files, symbols, schema changes, contracts, and integration points. Organize this section by implementation boundary or vertical slice. +## ASCII UI preview + +Required when rendered UI changes. Follow the preview contract in +`docs/specs/guide/plans-and-work-orders.md`: show labelled views, relevant +states, desktop/phone composition, and structural requirements. Omit for +packages without UI changes. + ## Tests Map every relevant acceptance criterion to its unit or integration evidence. @@ -159,6 +166,12 @@ State the implementation outcome in two or three sentences. - Give one to three concrete implementation conditions. +## ASCII UI preview + +For a UI work order, include its relevant view or excerpt from the plan with +the same view label, a link to the full preview, and applicable AC references. +Omit for work orders without rendered UI changes. + ## Verification ```bash @@ -234,7 +247,10 @@ review after the PR opens. ### 5. End the design turn Report the requirement IDs, system designs, plan, work orders, dependency -order, exact checks, and open risks. Then end the turn. +order, exact checks, and open risks. For UI changes, also render a compact +ASCII preview inline in the final conversation summary; links alone are not +enough. Follow the shared preview contract, including phone composition when +it differs. Then end the turn. Do not ask for plan approval or a model switch. The user reviews the artifacts and sends a later explicit implementation request. diff --git a/.agents/skills/spec-driven-development/SKILL.md b/.agents/skills/spec-driven-development/SKILL.md index e106e16f78..486f3fcf9b 100644 --- a/.agents/skills/spec-driven-development/SKILL.md +++ b/.agents/skills/spec-driven-development/SKILL.md @@ -96,6 +96,10 @@ Each work order must contain: Work orders do not name a worker role or model tier. Waves can identify parallel-safe candidates. They do not authorize subagents. +For rendered UI changes, include ASCII previews in the plan and relevant UI +work orders through `/plan`. Follow the shared contract in +`docs/specs/guide/plans-and-work-orders.md#ascii-ui-previews`. + ## Design-package handoff Before implementation, report: @@ -105,6 +109,8 @@ Before implementation, report: - The plan and work-order paths. - Dependency order and exact verification commands. - Open risks and exclusions. +- For UI changes, a compact inline ASCII preview of the proposed UI, with a + distinct phone view when composition differs, plus links to the full previews. Then end the turn. Do not ask the user to approve the package or switch models. The user reviews the files and sends a later implementation request. diff --git a/.agents/skills/tdd/SKILL.md b/.agents/skills/tdd/SKILL.md index 4f7b4cf4a4..2161184f0a 100644 --- a/.agents/skills/tdd/SKILL.md +++ b/.agents/skills/tdd/SKILL.md @@ -33,6 +33,12 @@ For UI rendering bugs, prefer extracting or using a pure helper and testing that ## Determine test scope +When implementing a UI work order, read its ASCII UI preview and linked plan +before changing the surface. Use the structural requirements to guide the +existing rendered checks; do not test ASCII whitespace or treat it as exact +pixel geometry. Keep any design revisions synchronized through +`docs/specs/guide/plans-and-work-orders.md#ascii-ui-previews`. + - **Go unit** (`apps/backend/`): test file next to source as `*_test.go`. Run: ```bash cd apps/backend && go test -v -run TestName ./internal/path/to/package/... diff --git a/apps/backend/config/workflows/loader.go b/apps/backend/config/workflows/loader.go index 31f3678ab4..0145da97e2 100644 --- a/apps/backend/config/workflows/loader.go +++ b/apps/backend/config/workflows/loader.go @@ -37,6 +37,7 @@ type stepDefYAML struct { AgentProfileID string `yaml:"agent_profile_id,omitempty"` ProfileSessionStartPolicy taskmodels.WorkflowProfileSessionStartPolicy `yaml:"profile_session_start_policy,omitempty"` ProfileSessionEndPolicy taskmodels.WorkflowProfileSessionEndPolicy `yaml:"profile_session_end_policy,omitempty"` + SessionTarget *models.WorkflowSessionTarget `yaml:"session_target,omitempty"` AutoAdvanceRequiresSignal bool `yaml:"auto_advance_requires_signal,omitempty"` CancelTriggersTurnComplete bool `yaml:"cancel_triggers_turn_complete,omitempty"` CompleteTaskOnEnter bool `yaml:"complete_task_on_enter,omitempty"` @@ -168,6 +169,11 @@ func convertStep(s stepDefYAML) (models.StepDefinition, error) { if err != nil { return models.StepDefinition{}, fmt.Errorf("step %q: %w", s.ID, err) } + if s.SessionTarget != nil { + if err := models.ValidateWorkflowSessionTarget(s.SessionTarget); err != nil { + return models.StepDefinition{}, fmt.Errorf("step %q: %w", s.ID, err) + } + } return models.StepDefinition{ ID: s.ID, Name: s.Name, @@ -182,6 +188,7 @@ func convertStep(s stepDefYAML) (models.StepDefinition, error) { AgentProfileID: s.AgentProfileID, ProfileSessionStartPolicy: taskmodels.NormalizeWorkflowProfileSessionStartPolicy(string(s.ProfileSessionStartPolicy)), ProfileSessionEndPolicy: taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(s.ProfileSessionEndPolicy)), + SessionTarget: models.CloneWorkflowSessionTarget(s.SessionTarget), AutoAdvanceRequiresSignal: s.AutoAdvanceRequiresSignal, CancelTriggersTurnComplete: s.CancelTriggersTurnComplete, CompleteTaskOnEnter: s.CompleteTaskOnEnter, diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index 482627403b..61a200702d 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -750,6 +750,7 @@ func mapKanbanStepState(step taskdto.WorkflowStepDTO) map[string]any { "agent_profile_id": nullString(step.AgentProfileID), "profile_session_start_policy": string(step.ProfileSessionStartPolicy), "profile_session_end_policy": string(step.ProfileSessionEndPolicy), + "session_target": step.SessionTarget, "stage_type": nullString(step.StageType), "wip_limit": step.WIPLimit, "pull_from_step_id": nullString(step.PullFromStepID), diff --git a/apps/backend/internal/mcp/handlers/config_workflow_handlers.go b/apps/backend/internal/mcp/handlers/config_workflow_handlers.go index 25ba857b9e..586dc05005 100644 --- a/apps/backend/internal/mcp/handlers/config_workflow_handlers.go +++ b/apps/backend/internal/mcp/handlers/config_workflow_handlers.go @@ -151,23 +151,24 @@ func (h *Handlers) handleImportWorkflow(ctx context.Context, msg *ws.Message) (* func (h *Handlers) handleCreateWorkflowStep(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { - WorkflowID string `json:"workflow_id"` - Name string `json:"name"` - Position int `json:"position"` - Color string `json:"color"` - Prompt string `json:"prompt"` - AgentProfileID *string `json:"agent_profile_id"` - ProfileSessionStartPolicy *string `json:"profile_session_start_policy"` - ProfileSessionEndPolicy *string `json:"profile_session_end_policy"` - IsStartStep *bool `json:"is_start_step"` - AllowManualMove *bool `json:"allow_manual_move"` - ShowInCommandPanel *bool `json:"show_in_command_panel"` - AutoAdvanceRequiresSignal *bool `json:"auto_advance_requires_signal"` - CancelTriggersTurnComplete *bool `json:"cancel_triggers_turn_complete"` - CompleteTaskOnEnter json.RawMessage `json:"complete_task_on_enter"` - WIPLimit *int `json:"wip_limit"` - PullFromStepID *string `json:"pull_from_step_id"` - Events *wfmodels.StepEvents `json:"events"` + WorkflowID string `json:"workflow_id"` + Name string `json:"name"` + Position int `json:"position"` + Color string `json:"color"` + Prompt string `json:"prompt"` + AgentProfileID *string `json:"agent_profile_id"` + ProfileSessionStartPolicy *string `json:"profile_session_start_policy"` + ProfileSessionEndPolicy *string `json:"profile_session_end_policy"` + IsStartStep *bool `json:"is_start_step"` + AllowManualMove *bool `json:"allow_manual_move"` + ShowInCommandPanel *bool `json:"show_in_command_panel"` + AutoAdvanceRequiresSignal *bool `json:"auto_advance_requires_signal"` + CancelTriggersTurnComplete *bool `json:"cancel_triggers_turn_complete"` + CompleteTaskOnEnter json.RawMessage `json:"complete_task_on_enter"` + WIPLimit *int `json:"wip_limit"` + PullFromStepID *string `json:"pull_from_step_id"` + SessionTarget workflowctrl.SessionTargetPatch `json:"session_target"` + Events *wfmodels.StepEvents `json:"events"` } if err := json.Unmarshal(msg.Payload, &req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) @@ -199,6 +200,7 @@ func (h *Handlers) handleCreateWorkflowStep(ctx context.Context, msg *ws.Message CompleteTaskOnEnter: completeTaskOnEnter, WIPLimit: req.WIPLimit, PullFromStepID: req.PullFromStepID, + SessionTarget: req.SessionTarget, Events: req.Events, } if req.AllowManualMove != nil { @@ -231,23 +233,24 @@ func optionalCompleteTaskOnEnter(raw json.RawMessage) (*bool, error) { func (h *Handlers) handleUpdateWorkflowStep(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { - StepID string `json:"step_id"` - Name *string `json:"name"` - Color *string `json:"color"` - Prompt *string `json:"prompt"` - AgentProfileID *string `json:"agent_profile_id"` - ProfileSessionStartPolicy *string `json:"profile_session_start_policy"` - ProfileSessionEndPolicy *string `json:"profile_session_end_policy"` - IsStartStep *bool `json:"is_start_step"` - AllowManualMove *bool `json:"allow_manual_move"` - ShowInCommandPanel *bool `json:"show_in_command_panel"` - AutoArchiveAfterHours *int `json:"auto_archive_after_hours"` - AutoAdvanceRequiresSignal *bool `json:"auto_advance_requires_signal"` - CancelTriggersTurnComplete *bool `json:"cancel_triggers_turn_complete"` - CompleteTaskOnEnter json.RawMessage `json:"complete_task_on_enter"` - WIPLimit *int `json:"wip_limit"` - PullFromStepID *string `json:"pull_from_step_id"` - Events *wfmodels.StepEvents `json:"events"` + StepID string `json:"step_id"` + Name *string `json:"name"` + Color *string `json:"color"` + Prompt *string `json:"prompt"` + AgentProfileID *string `json:"agent_profile_id"` + ProfileSessionStartPolicy *string `json:"profile_session_start_policy"` + ProfileSessionEndPolicy *string `json:"profile_session_end_policy"` + IsStartStep *bool `json:"is_start_step"` + AllowManualMove *bool `json:"allow_manual_move"` + ShowInCommandPanel *bool `json:"show_in_command_panel"` + AutoArchiveAfterHours *int `json:"auto_archive_after_hours"` + AutoAdvanceRequiresSignal *bool `json:"auto_advance_requires_signal"` + CancelTriggersTurnComplete *bool `json:"cancel_triggers_turn_complete"` + CompleteTaskOnEnter json.RawMessage `json:"complete_task_on_enter"` + WIPLimit *int `json:"wip_limit"` + PullFromStepID *string `json:"pull_from_step_id"` + SessionTarget workflowctrl.SessionTargetPatch `json:"session_target"` + Events *wfmodels.StepEvents `json:"events"` } if err := json.Unmarshal(msg.Payload, &req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) @@ -277,6 +280,7 @@ func (h *Handlers) handleUpdateWorkflowStep(ctx context.Context, msg *ws.Message CompleteTaskOnEnter: completeTaskOnEnter, WIPLimit: req.WIPLimit, PullFromStepID: req.PullFromStepID, + SessionTarget: req.SessionTarget, Events: req.Events, } diff --git a/apps/backend/internal/mcp/handlers/config_workflow_handlers_test.go b/apps/backend/internal/mcp/handlers/config_workflow_handlers_test.go index efc7354301..c774d90e33 100644 --- a/apps/backend/internal/mcp/handlers/config_workflow_handlers_test.go +++ b/apps/backend/internal/mcp/handlers/config_workflow_handlers_test.go @@ -497,7 +497,7 @@ func TestHandleExportWorkflow_ReturnsPortableWorkflow(t *testing.T) { var exported wfmodels.WorkflowExport require.NoError(t, json.Unmarshal(resp.Payload, &exported)) - assert.Equal(t, wfmodels.ExportVersion, exported.Version) + assert.Equal(t, wfmodels.LegacyExportVersion, exported.Version) assert.Equal(t, wfmodels.ExportType, exported.Type) require.Len(t, exported.Workflows, 1) assert.Equal(t, "Portable Board", exported.Workflows[0].Name) diff --git a/apps/backend/internal/mcp/server/config_handlers.go b/apps/backend/internal/mcp/server/config_handlers.go index 8e04cba912..103459eec8 100644 --- a/apps/backend/internal/mcp/server/config_handlers.go +++ b/apps/backend/internal/mcp/server/config_handlers.go @@ -12,6 +12,26 @@ import ( const mcpKeyCallerTaskID = "caller_task_id" +func nullableSessionTargetOption() mcp.ToolOption { + return func(tool *mcp.Tool) { + tool.InputSchema.Properties["session_target"] = map[string]interface{}{ + "type": []string{"object", "null"}, + "description": "Optional session recipient: {kind: 'initial'} or {kind: 'step', step_id: ''}. Set null to clear it.", + } + } +} + +func copyWorkflowStepArguments(payload map[string]interface{}, args map[string]interface{}, keys ...string) { + for _, key := range keys { + if value, present := args[key]; present && value != nil { + payload[key] = value + } + } + if value, present := args["session_target"]; present { + payload["session_target"] = value + } +} + // --- Workflow config tools --- func (s *Server) registerConfigWorkflowTools() { @@ -71,7 +91,7 @@ func (s *Server) registerConfigWorkflowTools() { ) s.mcpServer.AddTool( mcp.NewTool("export_workflow_kandev", - mcp.WithDescription("Export one workflow as a portable version 2 kandev_workflow JSON document. The result contains one workflow and its steps without instance IDs or timestamps. Pass the JSON text unchanged as document to import_workflow_kandev (the import document limit is 1 MiB)."), + mcp.WithDescription("Export one workflow as a portable kandev_workflow JSON document. Targeted workflows use version 2; target-free workflows use version 1. The result contains one workflow and its steps without instance IDs or timestamps. Pass the JSON text unchanged as document to import_workflow_kandev (the import document limit is 1 MiB)."), mcp.WithReadOnlyHintAnnotation(true), mcp.WithDestructiveHintAnnotation(false), mcp.WithIdempotentHintAnnotation(true), @@ -110,6 +130,7 @@ func (s *Server) registerConfigWorkflowStepTools() { mcp.WithBoolean("complete_task_on_enter", mcp.Description("Complete the task when it enters this final workflow step")), mcp.WithNumber("wip_limit", mcp.Description("Work-in-progress limit for this step. 0 means unlimited.")), mcp.WithString("pull_from_step_id", mcp.Description("Optional feeder workflow step ID to pull from when capacity opens.")), + nullableSessionTargetOption(), mcp.WithObject("events", mcp.Description("Event-driven actions. Keys: on_enter, on_exit, on_turn_start, on_turn_complete. Each is an array of {type, config} objects.")), ), s.wrapHandler("create_workflow_step_kandev", s.createWorkflowStepHandler()), @@ -133,6 +154,7 @@ func (s *Server) registerConfigWorkflowStepTools() { mcp.WithBoolean("complete_task_on_enter", mcp.Description("Complete the task when it enters this final workflow step")), mcp.WithNumber("wip_limit", mcp.Description("Work-in-progress limit for this step. 0 means unlimited.")), mcp.WithString("pull_from_step_id", mcp.Description("Optional feeder workflow step ID to pull from when capacity opens.")), + nullableSessionTargetOption(), mcp.WithObject("events", mcp.Description("Event-driven actions. Keys: on_enter, on_exit, on_turn_start, on_turn_complete.")), ), s.wrapHandler("update_workflow_step_kandev", s.updateWorkflowStepHandler()), @@ -498,11 +520,7 @@ func (s *Server) createWorkflowStepHandler() server.ToolHandlerFunc { payload["prompt"] = prompt } args := req.GetArguments() - for _, key := range []string{"position", "is_start_step", "allow_manual_move", "show_in_command_panel", "agent_profile_id", "profile_session_start_policy", "profile_session_end_policy", "auto_advance_requires_signal", "cancel_triggers_turn_complete", "complete_task_on_enter", "wip_limit", "pull_from_step_id", "events"} { - if args[key] != nil { - payload[key] = args[key] - } - } + copyWorkflowStepArguments(payload, args, "position", "is_start_step", "allow_manual_move", "show_in_command_panel", "agent_profile_id", "profile_session_start_policy", "profile_session_end_policy", "auto_advance_requires_signal", "cancel_triggers_turn_complete", "complete_task_on_enter", "wip_limit", "pull_from_step_id", "events") return s.forwardToBackend(ctx, ws.ActionMCPCreateWorkflowStep, payload) } } @@ -524,11 +542,7 @@ func (s *Server) updateWorkflowStepHandler() server.ToolHandlerFunc { payload["prompt"] = prompt } args := req.GetArguments() - for _, key := range []string{"is_start_step", "allow_manual_move", "show_in_command_panel", "agent_profile_id", "profile_session_start_policy", "profile_session_end_policy", "auto_archive_after_hours", "auto_advance_requires_signal", "cancel_triggers_turn_complete", "complete_task_on_enter", "wip_limit", "pull_from_step_id", "events"} { - if args[key] != nil { - payload[key] = args[key] - } - } + copyWorkflowStepArguments(payload, args, "is_start_step", "allow_manual_move", "show_in_command_panel", "agent_profile_id", "profile_session_start_policy", "profile_session_end_policy", "auto_archive_after_hours", "auto_advance_requires_signal", "cancel_triggers_turn_complete", "complete_task_on_enter", "wip_limit", "pull_from_step_id", "events") return s.forwardToBackend(ctx, ws.ActionMCPUpdateWorkflowStep, payload) } } diff --git a/apps/backend/internal/mcp/server/config_handlers_test.go b/apps/backend/internal/mcp/server/config_handlers_test.go index cc4c195ac5..e64b9fcade 100644 --- a/apps/backend/internal/mcp/server/config_handlers_test.go +++ b/apps/backend/internal/mcp/server/config_handlers_test.go @@ -148,6 +148,18 @@ func TestWorkflowStepTools_SchemaExposesProfileAndSessionPolicies(t *testing.T) assert.Contains(t, updateProps, "profile_session_end_policy") } +func TestWorkflowStepTools_SchemaExposesSessionTarget(t *testing.T) { + backend := &testBackend{} + s := newTestServer(t, backend) + + createProps := toolInputProperties(t, s, "create_workflow_step_kandev") + updateProps := toolInputProperties(t, s, "update_workflow_step_kandev") + assert.Contains(t, createProps, "session_target") + assert.Contains(t, updateProps, "session_target") + assert.Equal(t, []interface{}{"object", "null"}, createProps["session_target"].(map[string]interface{})["type"]) + assert.Equal(t, []interface{}{"object", "null"}, updateProps["session_target"].(map[string]interface{})["type"]) +} + func TestCreateWorkflowHandler_Success(t *testing.T) { backend := &testBackend{ response: map[string]interface{}{"id": "wf-1", "name": "Sprint Board"}, @@ -315,6 +327,7 @@ func TestCreateWorkflowStepHandler_AllFields(t *testing.T) { "agent_profile_id": "profile-deploy", "profile_session_start_policy": "reuse", "profile_session_end_policy": "park", + "session_target": map[string]interface{}{"kind": "initial"}, "is_start_step": true, "allow_manual_move": true, "show_in_command_panel": true, @@ -334,6 +347,7 @@ func TestCreateWorkflowStepHandler_AllFields(t *testing.T) { assert.Equal(t, "profile-deploy", payload["agent_profile_id"]) assert.Equal(t, "reuse", payload["profile_session_start_policy"]) assert.Equal(t, "park", payload["profile_session_end_policy"]) + assert.Equal(t, map[string]interface{}{"kind": "initial"}, payload["session_target"]) assert.Equal(t, true, payload["auto_advance_requires_signal"]) assert.NotNil(t, payload["events"]) } @@ -440,6 +454,30 @@ func TestUpdateWorkflowStepHandler_ForwardsCancelTriggersTurnComplete(t *testing assert.Equal(t, false, payload["cancel_triggers_turn_complete"]) } +func TestWorkflowStepHandlersForwardExplicitNullSessionTarget(t *testing.T) { + for _, toolName := range []string{"create_workflow_step_kandev", "update_workflow_step_kandev"} { + t.Run(toolName, func(t *testing.T) { + backend := &testBackend{response: map[string]interface{}{"step": map[string]interface{}{"id": "step-1"}}} + s := newTestServer(t, backend) + args := map[string]interface{}{"session_target": nil} + if toolName == "create_workflow_step_kandev" { + args["workflow_id"] = "workflow-1" + args["name"] = "Review" + } else { + args["step_id"] = "step-1" + } + + result := callTool(t, s, toolName, args) + require.False(t, result.IsError) + payload, ok := backend.lastPayload.(map[string]interface{}) + require.True(t, ok) + value, present := payload["session_target"] + require.True(t, present) + require.Nil(t, value) + }) + } +} + func TestUpdateWorkflowStepHandler_MissingStepID(t *testing.T) { backend := &testBackend{} s := newTestServer(t, backend) diff --git a/apps/backend/internal/orchestrator/event_handlers_initial_prompt_dedup_test.go b/apps/backend/internal/orchestrator/event_handlers_initial_prompt_dedup_test.go index f7b6c29665..395a56fd4a 100644 --- a/apps/backend/internal/orchestrator/event_handlers_initial_prompt_dedup_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_initial_prompt_dedup_test.go @@ -241,6 +241,39 @@ func TestWorkflowAutoStartPlanModeOnlyPromptForCreatedSession(t *testing.T) { } } +func TestWorkflowAutoStartEmptyPromptForCreatedSessionStartsAgent(t *testing.T) { + fixture := newInitialPromptDedupFixture(t, true, false) + fixture.session.State = models.TaskSessionStateCreated + fixture.session.AgentProfileID = "profile-initial-prompt-dedup" + if err := fixture.repo.UpdateTaskSession(context.Background(), fixture.session); err != nil { + t.Fatalf("update created session: %v", err) + } + fixture.svc.scheduler = scheduler.NewScheduler( + queue.NewTaskQueue(10), fixture.svc.executor, fixture.svc.taskRepo, + testLogger(), scheduler.SchedulerConfig{}, + ) + fixture.svc.activeTurns.Store(fixture.sessionID, "turn-initial-prompt-dedup") + startCalled := make(chan struct{}) + fixture.agent.startAgentProcessFunc = func(_ context.Context, _ string) error { + close(startCalled) + return nil + } + + if err := fixture.svc.autoStartStepPrompt( + context.Background(), fixture.taskID, fixture.session, fixture.step, "", false, true, nil, + ); err != nil { + t.Fatalf("autoStartStepPrompt returned error: %v", err) + } + select { + case <-startCalled: + case <-time.After(2 * time.Second): + t.Fatal("empty auto-start prompt did not launch the created session") + } + if got := len(fixture.messages.userMessages); got != 0 { + t.Fatalf("recorded user messages = %d, want 0", got) + } +} + func TestStartSessionForWorkflowStepPlanModeOnlyPrompt(t *testing.T) { fixture := newInitialPromptDedupFixture(t, true, false) fixture.step.Events.OnEnter = []wfmodels.OnEnterAction{{Type: wfmodels.OnEnterEnablePlanMode}} diff --git a/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go b/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go index bbae3ae8a0..e38e584af6 100644 --- a/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go @@ -440,7 +440,8 @@ func newPendingMoveStepGetter() *mockStepGetter { sg := newMockStepGetter() sg.steps[stepInProgressID] = &wfmodels.WorkflowStep{ ID: stepInProgressID, WorkflowID: "wf1", Name: "In Progress", Position: 1, - AgentProfileID: profileImpl, + AgentProfileID: profileImpl, + ProfileSessionEndPolicy: models.WorkflowProfileSessionEndPolicyComplete, Events: wfmodels.StepEvents{ OnEnter: []wfmodels.OnEnterAction{{Type: wfmodels.OnEnterAutoStartAgent}}, OnTurnComplete: []wfmodels.OnTurnCompleteAction{ @@ -450,7 +451,8 @@ func newPendingMoveStepGetter() *mockStepGetter { } sg.steps[stepInReviewID] = &wfmodels.WorkflowStep{ ID: stepInReviewID, WorkflowID: "wf1", Name: "In Review", Position: 2, - AgentProfileID: profileReview, + AgentProfileID: profileReview, + ProfileSessionEndPolicy: models.WorkflowProfileSessionEndPolicyComplete, Events: wfmodels.StepEvents{ OnEnter: []wfmodels.OnEnterAction{{Type: wfmodels.OnEnterAutoStartAgent}}, OnTurnComplete: []wfmodels.OnTurnCompleteAction{ diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 7e5475e9dd..ed2b9cb233 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -859,7 +859,7 @@ func (s *Service) handleTaskQueuePromoted(ctx context.Context, data watcher.Task s.processParentChildrenCompletedForTerminalStepMove(ctx, task.ID, targetStep.ID) if session != nil { go func() { - if err := s.finalizeStepEnter(context.WithoutCancel(ctx), task.ID, session.ID, targetStep, task.Description, targetStep.HasOnEnterAction(wfmodels.OnEnterAutoStartAgent), sourceStep); err != nil { + if err := s.finalizeStepEnter(context.WithoutCancel(ctx), task.ID, session.ID, targetStep, task.Description, targetStep.HasOnEnterAction(wfmodels.OnEnterAutoStartAgent), sourceStep, data.StepTransitionID); err != nil { s.restoreTaskLifecycleToken(context.WithoutCancel(ctx), task.ID, models.MetaKeyQueuePromotionPending, queuePromotionToken, "task.queue_promoted") return } @@ -1610,7 +1610,7 @@ func (s *Service) autoStartTaskForLoadedStep(ctx context.Context, task *models.T if workflowAgentProfileID != "" { startAgentProfileID = "" } - _, err := s.StartTask(asyncCtx, task.ID, startAgentProfileID, executorID, executorProfileID, "", task.Description, step.ID, planMode, true, nil) + _, err := s.startTask(asyncCtx, task.ID, startAgentProfileID, executorID, executorProfileID, "", task.Description, step.ID, planMode, true, nil, startTaskOptions{WorkflowEntryID: stepTransitionID}) if err != nil { s.logger.Error(eventName+": failed to auto-start task", zap.String("task_id", task.ID), @@ -1962,14 +1962,14 @@ func (s *Service) fromStepAndTargetForTaskMoved( if manualBarrier { go s.processManualMoveLifecycleWithFeederBarrier( context.WithoutCancel(ctx), data.TaskID, session, fromStep, targetStep, - data.FromStepID, data.ToStepID, data.TaskDescription, + data.FromStepID, data.ToStepID, data.TaskDescription, data.StepTransitionID, ) return } go func() { if err := s.processStepExitAndEnterWithSteps( context.WithoutCancel(ctx), data.TaskID, session, fromStep, targetStep, - data.FromStepID, data.ToStepID, data.TaskDescription, data.QueuePromotion, queuePromotionToken, + data.FromStepID, data.ToStepID, data.TaskDescription, data.QueuePromotion, queuePromotionToken, data.StepTransitionID, ); err != nil { s.logger.Warn("task.moved: step exit and enter lifecycle failed", zap.String("task_id", data.TaskID), @@ -2226,6 +2226,7 @@ func (s *Service) processManualMoveLifecycleWithFeederBarrier( session *models.TaskSession, fromStep, targetStep *wfmodels.WorkflowStep, fromStepID, toStepID, taskDescription string, + entryIDs ...int64, ) { lockValue, _ := s.queuedMoveLifecycleLocks.LoadOrStore(taskID, &sync.Mutex{}) lock := lockValue.(*sync.Mutex) @@ -2247,7 +2248,7 @@ func (s *Service) processManualMoveLifecycleWithFeederBarrier( } if err := s.processStepExitAndEnterWithSteps( ctx, taskID, session, fromStep, targetStep, - fromStepID, toStepID, taskDescription, false, nil, + fromStepID, toStepID, taskDescription, false, nil, entryIDs..., ); err != nil { s.logger.Warn("manual move lifecycle stopped before completion", zap.String("task_id", taskID), zap.String("from_step_id", fromStepID), @@ -2292,6 +2293,7 @@ func (s *Service) processStepExitAndEnterWithSteps( session *models.TaskSession, fromStep, targetStep *wfmodels.WorkflowStep, fromStepID, toStepID, taskDescription string, queuePromotion bool, queuePromotionToken interface{}, + entryIDs ...int64, ) error { if fromStep == nil { var err error @@ -2317,7 +2319,7 @@ func (s *Service) processStepExitAndEnterWithSteps( } clearReview := targetStep.HasOnEnterAction(wfmodels.OnEnterAutoStartAgent) - if err := s.finalizeStepEnter(ctx, taskID, session.ID, targetStep, taskDescription, clearReview, fromStep); err != nil { + if err := s.finalizeStepEnter(ctx, taskID, session.ID, targetStep, taskDescription, clearReview, fromStep, entryIDs...); err != nil { if queuePromotion { s.restoreTaskLifecycleToken(ctx, taskID, models.MetaKeyQueuePromotionPending, queuePromotionToken, "task.moved") } @@ -2329,7 +2331,7 @@ func (s *Service) processStepExitAndEnterWithSteps( // finalizeStepEnter optionally clears review status, reloads the session, and // processes on_enter actions for the target step. Shared by executeStepTransition // and processStepExitAndEnter. -func (s *Service) finalizeStepEnter(ctx context.Context, taskID, sessionID string, targetStep *wfmodels.WorkflowStep, taskDescription string, clearReview bool, sourceStep *wfmodels.WorkflowStep) error { +func (s *Service) finalizeStepEnter(ctx context.Context, taskID, sessionID string, targetStep *wfmodels.WorkflowStep, taskDescription string, clearReview bool, sourceStep *wfmodels.WorkflowStep, entryIDs ...int64) error { if clearReview { if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil { s.logger.Warn("failed to clear session review status", @@ -2355,7 +2357,11 @@ func (s *Service) finalizeStepEnter(ctx context.Context, taskID, sessionID strin // path" and skip with a log rather than executing — see // docs/specs/workflow-on-enter-action-dispatch/spec.md and the task // plan's scope note for why E2-E5 dispatch is deferred. - s.processOnEnter(ctx, taskID, session, targetStep, taskDescription, 0, sourceStep) + var entryID int64 + if len(entryIDs) > 0 { + entryID = entryIDs[0] + } + s.processOnEnter(ctx, taskID, session, targetStep, taskDescription, entryID, sourceStep) return nil } @@ -2381,6 +2387,9 @@ func (s *Service) resolveStepPlanMode(ctx context.Context, session *models.TaskS // resolveStepAgentProfile returns the effective agent profile ID for a step. // Resolution order: step override -> workflow default -> empty (use current session's profile). func (s *Service) resolveStepAgentProfile(ctx context.Context, step *wfmodels.WorkflowStep) string { + if step != nil && step.SessionTarget != nil { + return "" + } if step.AgentProfileID != "" { return step.AgentProfileID } @@ -2408,10 +2417,10 @@ func (s *Service) resolveStepProfileSessionStartPolicy(step *wfmodels.WorkflowSt } // resolveStepProfileSessionEndPolicy returns the source step's session end -// behavior. Invalid or absent values use the safe complete default. +// behavior. Invalid or absent values use the conversation-preserving park default. func (s *Service) resolveStepProfileSessionEndPolicy(step *wfmodels.WorkflowStep) models.WorkflowProfileSessionEndPolicy { if step == nil { - return models.WorkflowProfileSessionEndPolicyComplete + return models.WorkflowProfileSessionEndPolicyPark } return models.NormalizeWorkflowProfileSessionEndPolicy(string(step.ProfileSessionEndPolicy)) } @@ -2793,6 +2802,7 @@ func (s *Service) reuseSessionForStepWithEndPolicy( taskID string, currentSession, existing *models.TaskSession, endPolicy models.WorkflowProfileSessionEndPolicy, + routes ...*models.WorkflowSessionRoute, ) (*models.TaskSession, error) { endPolicy = models.NormalizeWorkflowProfileSessionEndPolicy(string(endPolicy)) s.logger.Info("reusing existing session for profile", @@ -2802,18 +2812,30 @@ func (s *Service) reuseSessionForStepWithEndPolicy( zap.String("reused_profile", existing.AgentProfileID), zap.String("reused_state", string(existing.State))) - promoted, err := s.setNonterminalSessionPrimary(ctx, existing.ID) + var promoted bool + var err error + var route *models.WorkflowSessionRoute + if len(routes) > 0 { + route = routes[0] + } + if route != nil { + promoted, err = s.promoteWorkflowSessionRoute(ctx, taskID, existing, route) + } else { + promoted, err = s.setNonterminalSessionPrimary(ctx, existing.ID) + } if err != nil { return nil, fmt.Errorf("conditional primary promotion: %w", err) } if !promoted { return nil, errReusableSessionNoLongerActive } - if task, taskErr := s.repo.GetTask(ctx, taskID); taskErr != nil { - s.logger.Warn("failed to load task after promoting reused session", - zap.String("task_id", taskID), zap.Error(taskErr)) - } else if task != nil { - s.publishTaskUpdated(ctx, task) + if route == nil { + if task, taskErr := s.repo.GetTask(ctx, taskID); taskErr != nil { + s.logger.Warn("failed to load task after promoting reused session", + zap.String("task_id", taskID), zap.Error(taskErr)) + } else if task != nil { + s.publishTaskUpdated(ctx, task) + } } // Parking can fail while persisting stop ownership. Do it before queue @@ -2895,11 +2917,22 @@ func (s *Service) createNewSessionForStepWithEndPolicy( currentSession *models.TaskSession, newAgentProfileID string, endPolicy models.WorkflowProfileSessionEndPolicy, +) (*models.TaskSession, error) { + return s.createNewSessionForStepWithEndPolicyAndRoute(ctx, taskID, currentSession, newAgentProfileID, endPolicy, nil) +} + +func (s *Service) createNewSessionForStepWithEndPolicyAndRoute( + ctx context.Context, + taskID string, + currentSession *models.TaskSession, + newAgentProfileID string, + endPolicy models.WorkflowProfileSessionEndPolicy, + workflowRoute *models.WorkflowSessionRoute, ) (*models.TaskSession, error) { endPolicy = models.NormalizeWorkflowProfileSessionEndPolicy(string(endPolicy)) // Prepare the new session BEFORE touching the old one. // If any step below fails, the old session remains active and the task stays recoverable. - newSession, err := s.prepareWorkflowReplacementSession(ctx, taskID, currentSession, newAgentProfileID) + newSession, err := s.prepareWorkflowReplacementSession(ctx, taskID, currentSession, newAgentProfileID, workflowRoute) if err != nil { return nil, err } @@ -2907,7 +2940,15 @@ func (s *Service) createNewSessionForStepWithEndPolicy( // Promote the new session to primary so it's loaded when navigating back to this task. // Use SetPrimarySession (not repo.SetSessionPrimary) to broadcast a task.updated WS // event — the frontend reads primarySessionId from the task to render the star icon. - if err := s.SetPrimarySession(ctx, newSession.ID); err != nil { + if workflowRoute != nil { + promoted, promoteErr := s.promoteWorkflowSessionRoute(ctx, taskID, newSession, workflowRoute) + if promoteErr != nil { + return nil, fmt.Errorf("failed to promote new workflow session: %w", promoteErr) + } + if !promoted { + return nil, errReusableSessionNoLongerActive + } + } else if err := s.SetPrimarySession(ctx, newSession.ID); err != nil { return nil, fmt.Errorf("failed to promote new workflow session: %w", err) } @@ -2941,6 +2982,7 @@ func (s *Service) prepareWorkflowReplacementSession( taskID string, currentSession *models.TaskSession, newAgentProfileID string, + workflowRoute *models.WorkflowSessionRoute, ) (*models.TaskSession, error) { task, err := s.scheduler.GetTask(ctx, taskID) if err != nil { @@ -2961,7 +3003,12 @@ func (s *Service) prepareWorkflowReplacementSession( // Create a new session with the new agent profile. // Reuse the same executor profile from the current session. - sessionID, err := s.executor.PrepareSessionForExistingEnvironment(ctx, task, newAgentProfileID, currentSession.ExecutorID, currentSession.ExecutorProfileID, dbTask.WorkflowStepID, currentSession.TaskEnvironmentID) + var sessionID string + if workflowRoute != nil { + sessionID, err = s.executor.PrepareSessionForExistingEnvironmentWithWorkflowRoute(ctx, task, newAgentProfileID, currentSession.ExecutorID, currentSession.ExecutorProfileID, dbTask.WorkflowStepID, currentSession.TaskEnvironmentID, workflowRoute) + } else { + sessionID, err = s.executor.PrepareSessionForExistingEnvironment(ctx, task, newAgentProfileID, currentSession.ExecutorID, currentSession.ExecutorProfileID, dbTask.WorkflowStepID, currentSession.TaskEnvironmentID) + } if err != nil { return nil, fmt.Errorf("failed to prepare new session: %w", err) } @@ -3172,7 +3219,7 @@ func (s *Service) completeAndStopSession(ctx context.Context, taskID string, ses // lifecycle helpers regardless of the source session transport. func (s *Service) prepareWorkflowStepSession( ctx context.Context, taskID string, session *models.TaskSession, step *wfmodels.WorkflowStep, - sourceStep *wfmodels.WorkflowStep, + sourceStep *wfmodels.WorkflowStep, entryIDs ...int64, ) (*models.TaskSession, bool, error) { if session == nil { return nil, false, fmt.Errorf("workflow step session is nil") @@ -3181,6 +3228,9 @@ func (s *Service) prepareWorkflowStepSession( return nil, false, fmt.Errorf("workflow step is nil") } ctx = withWorkflowMetaCache(ctx) + if step.SessionTarget != nil { + return s.prepareExplicitWorkflowSession(ctx, taskID, session, step, sourceStep, entryIDs...) + } effectiveProfile := s.resolveStepAgentProfile(ctx, step) if effectiveProfile == "" || effectiveProfile == session.AgentProfileID { s.tagSessionAsWorkflowSwitched(ctx, session.ID) @@ -3195,6 +3245,9 @@ func (s *Service) prepareWorkflowStepSession( session.IsPrimary = true } } + if err := s.recordWorkflowSourceBinding(ctx, taskID, step, session, entryIDs...); err != nil { + return nil, false, err + } return session, false, nil } startPolicy := s.resolveStepProfileSessionStartPolicy(step) @@ -3206,6 +3259,9 @@ func (s *Service) prepareWorkflowStepSession( if err != nil { return nil, false, err } + if err := s.recordWorkflowSourceBinding(ctx, taskID, step, newSession, entryIDs...); err != nil { + return nil, false, err + } return newSession, true, nil } @@ -3219,6 +3275,26 @@ func (s *Service) preflightWorkflowStepCredentials( return nil } ctx = withWorkflowMetaCache(ctx) + if targetStep.SessionTarget != nil { + resolution, err := s.resolveWorkflowSessionTarget(ctx, taskID, targetStep) + if err != nil { + return err + } + targetSession := currentSession + if s.resolveStepProfileSessionStartPolicy(targetStep) == models.WorkflowProfileSessionStartPolicyReuse && resolution.session != nil { + targetSession = resolution.session + } + task, err := s.repo.GetTask(ctx, taskID) + if err != nil { + return fmt.Errorf("get task for explicit credential preflight: %w", err) + } + if task == nil { + return fmt.Errorf("task %s not found for explicit credential preflight", taskID) + } + return s.executor.PreflightManagedGitCredentials( + ctx, task.WorkspaceID, taskID, targetSession.ExecutorID, targetSession.ExecutorProfileID, + ) + } effectiveProfile := s.resolveStepAgentProfile(ctx, targetStep) if effectiveProfile == "" || effectiveProfile == currentSession.AgentProfileID { return nil @@ -3251,9 +3327,9 @@ func (s *Service) preflightWorkflowStepCredentials( // workflow-engine dispatch. func (s *Service) maybySwitchSessionForProfile( ctx context.Context, taskID string, session *models.TaskSession, step *wfmodels.WorkflowStep, - sourceStep *wfmodels.WorkflowStep, + sourceStep *wfmodels.WorkflowStep, entryIDs ...int64, ) (*models.TaskSession, bool) { - effective, _, err := s.prepareWorkflowStepSession(ctx, taskID, session, step, sourceStep) + effective, _, err := s.prepareWorkflowStepSession(ctx, taskID, session, step, sourceStep, entryIDs...) if err != nil { s.logger.Error("failed to switch session for step agent profile", zap.String("task_id", taskID), @@ -3368,11 +3444,10 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo ctx = context.WithoutCancel(ctx) // One GetWorkflowMeta read shared by profile resolution and prompt build. ctx = withWorkflowMetaCache(ctx) - // Switch session if this step requires a different agent profile. var ok bool prevSessionID := session.ID - if session, ok = s.maybySwitchSessionForProfile(ctx, taskID, session, step, sourceStep); !ok { + if session, ok = s.maybySwitchSessionForProfile(ctx, taskID, session, step, sourceStep, entryID); !ok { return } sessionSwitched := session.ID != prevSessionID @@ -3432,7 +3507,6 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo if dispatchResult.aborted { return } - s.launchAfterOnEnterDispatch(ctx, taskID, session, step, taskDescription, hasPlanMode, dispatchResult.hasAutoStart, sessionSwitched) } @@ -4767,10 +4841,13 @@ func (s *Service) autoStartStepPrompt( prompt = mergedPrompt agentPrompt := AppendEntityReferenceContext(prompt, references) effectiveAgentPrompt := s.effectivePromptForSession(sessionID, agentPrompt, planMode, session) - if strings.TrimSpace(effectiveAgentPrompt) == "" && len(attachments) == 0 && queuedHandoff == "" { - // The session is already running after ensureSessionRunning; no prompt - // means we leave it waiting for the first user message rather than - // auto-starting. Attachment-only handoffs are admitted below. + if strings.TrimSpace(effectiveAgentPrompt) == "" && len(attachments) == 0 && queuedHandoff == "" && + session.State != models.TaskSessionStateCreated { + // Existing sessions are already running after ensureSessionRunning; no + // prompt means we leave them waiting for the first user message rather + // than auto-starting. A CREATED session is different: the explicit + // auto-start action must launch its prepared workspace even when the step + // has no prompt, while recordAutoStartMessage still skips an empty row. return nil } diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_passthrough_profile_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_passthrough_profile_test.go index 2543d24cd8..c2d0c150a6 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_passthrough_profile_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_passthrough_profile_test.go @@ -58,6 +58,7 @@ func TestPrepareWorkflowStepSessionSwitchesPassthroughProfile(t *testing.T) { stepGetter := newMockStepGetter() sourceStep := &wfmodels.WorkflowStep{ ID: "step1", WorkflowID: "wf1", AgentProfileID: "profile-a", + ProfileSessionEndPolicy: models.WorkflowProfileSessionEndPolicyComplete, } step := &wfmodels.WorkflowStep{ ID: "step2", WorkflowID: "wf1", AgentProfileID: "profile-b", diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_resolution_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_resolution_test.go index becd3bf971..f5cefad1cc 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_resolution_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_resolution_test.go @@ -11,6 +11,27 @@ import ( wfmodels "github.com/kandev/kandev/internal/workflow/models" ) +func TestResolveStepProfileSessionEndPolicyDefaultsToPark(t *testing.T) { + svc := &Service{} + + for name, step := range map[string]*wfmodels.WorkflowStep{ + "missing step": nil, + "empty policy": {}, + "invalid policy": {ProfileSessionEndPolicy: models.WorkflowProfileSessionEndPolicy("retain")}, + } { + t.Run(name, func(t *testing.T) { + if got := svc.resolveStepProfileSessionEndPolicy(step); got != models.WorkflowProfileSessionEndPolicyPark { + t.Fatalf("resolveStepProfileSessionEndPolicy() = %q, want park", got) + } + }) + } + + explicitComplete := &wfmodels.WorkflowStep{ProfileSessionEndPolicy: models.WorkflowProfileSessionEndPolicyComplete} + if got := svc.resolveStepProfileSessionEndPolicy(explicitComplete); got != models.WorkflowProfileSessionEndPolicyComplete { + t.Fatalf("explicit complete policy = %q, want complete", got) + } +} + func TestProcessStepExitAndEnter_UnknownSourceKeepsCurrentSessionRecoverable(t *testing.T) { ctx := context.Background() fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyNew, models.WorkflowProfileSessionEndPolicyPark) diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go index a6f31b360c..93fd957934 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go @@ -583,9 +583,10 @@ func TestSwitchWorkflowDispatcherRoutesOnEnterToDestinationProfileSession(t *tes stepGetter := newMockStepGetter() stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ - ID: "step1", - WorkflowID: "wf1", - AgentProfileID: "profile-a", + ID: "step1", + WorkflowID: "wf1", + AgentProfileID: "profile-a", + ProfileSessionEndPolicy: models.WorkflowProfileSessionEndPolicyComplete, } stepGetter.steps["step2"] = &wfmodels.WorkflowStep{ ID: "step2", @@ -795,9 +796,10 @@ func TestSwitchWorkflowDispatcherOnEnterSkipsSessionIndependentAction(t *testing stepGetter := newMockStepGetter() stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ - ID: "step1", - WorkflowID: "wf1", - AgentProfileID: "profile-a", + ID: "step1", + WorkflowID: "wf1", + AgentProfileID: "profile-a", + ProfileSessionEndPolicy: models.WorkflowProfileSessionEndPolicyComplete, } stepGetter.steps["step2"] = &wfmodels.WorkflowStep{ ID: "step2", @@ -1623,6 +1625,7 @@ func TestProcessOnEnter_ProfileSwitch(t *testing.T) { sg := newMockStepGetter() sourceStep := &wfmodels.WorkflowStep{ ID: "step1", WorkflowID: "wf1", Name: "Plan", AgentProfileID: "profile-a", + ProfileSessionEndPolicy: models.WorkflowProfileSessionEndPolicyComplete, } step := &wfmodels.WorkflowStep{ ID: "step2", diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_prompt_claim_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_prompt_claim_test.go index c08ad19b94..e8644dfe58 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_prompt_claim_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_prompt_claim_test.go @@ -435,6 +435,7 @@ func testProcessOnEnterTerminalizedAfterPromptClaim(t *testing.T, terminalState stepGetter := newMockStepGetter() sourceStep := &wfmodels.WorkflowStep{ ID: "step-source", WorkflowID: "wf1", Name: "Source", AgentProfileID: source.AgentProfileID, + ProfileSessionEndPolicy: models.WorkflowProfileSessionEndPolicyComplete, } stepGetter.steps[sourceStep.ID] = sourceStep step := &wfmodels.WorkflowStep{ @@ -745,6 +746,7 @@ func testProcessOnEnterImplicitProfileSwitchTerminalizedGuard( // (which inherits dbTask.WorkflowStepID) is resolvable. stepGetter.steps["step-source"] = &wfmodels.WorkflowStep{ ID: "step-source", WorkflowID: "wf1", Name: "Source", AgentProfileID: source.AgentProfileID, + ProfileSessionEndPolicy: models.WorkflowProfileSessionEndPolicyComplete, } step := &wfmodels.WorkflowStep{ ID: "step-implicit-switch", diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_test.go index a5a9c5a4d0..36b678ef3c 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_test.go @@ -564,8 +564,12 @@ func TestPublishSessionWaitingEvent(t *testing.T) { if _, exists := data["agent_profile_id"]; exists { t.Errorf("expected agent_profile_id to be absent, got %v", data["agent_profile_id"]) } - if _, exists := data["session_metadata"]; exists { - t.Errorf("expected session_metadata to be absent, got %v", data["session_metadata"]) + metadata, exists := data["session_metadata"].(map[string]interface{}) + if !exists { + t.Fatalf("expected initial session metadata, got %v", data["session_metadata"]) + } + if metadata[models.SessionMetaKeyOrigin] != models.SessionOriginTaskInitial { + t.Errorf("expected initial session origin, got %v", metadata[models.SessionMetaKeyOrigin]) } }) diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 9a270443f5..1624cfb0df 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -124,6 +124,26 @@ type initialRuntimeSeedTaskSessionCreator interface { CreateTaskSessionWithInitialRuntimeSeed(context.Context, *models.TaskSession) error } +// Workflow-route-aware creators keep a prepared destination record in the +// same transaction as session insertion. Repositories that do not expose the +// specialized workspace variants retain the legacy creation path for tests +// and older adapters. +type workflowSessionRouteTaskSessionCreator interface { + CreateTaskSessionWithWorkflowSessionRoute(context.Context, *models.TaskSession, *models.WorkflowSessionRoute) error +} + +type initialRuntimeSeedWorkflowRouteTaskSessionCreator interface { + CreateTaskSessionWithInitialRuntimeSeedAndWorkflowRoute(context.Context, *models.TaskSession, *models.WorkflowSessionRoute) error +} + +type workspaceBindingWorkflowRouteTaskSessionCreator interface { + CreateTaskSessionWithWorkspaceBindingAndWorkflowRoute(context.Context, *models.TaskSession, *models.TaskEnvironment, *models.WorkflowSessionRoute) error +} + +type sharedGroupWorkspaceBindingWorkflowRouteTaskSessionCreator interface { + CreateTaskSessionWithSharedGroupWorkspaceBindingAndWorkflowRoute(context.Context, *models.TaskSession, *models.TaskEnvironment, string, *models.WorkflowSessionRoute) error +} + // taskEnvironmentMaterializationFinalizer publishes a successfully prepared // canonical environment only after its complete repository inventory is saved. // It is kept narrow so legacy test stores retain the existing fallback path. diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index ba0468c8f0..1c711d7ac0 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -833,7 +833,18 @@ func (e *Executor) ExecuteWithFullProfile(ctx context.Context, task *v1.Task, ag // This allows the caller to get the session ID immediately and launch the agent later. // Returns the session ID. func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfileID string, executorID string, executorProfileID string, workflowStepID string) (string, error) { - return e.prepareSession(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID, true, "") + return e.prepareSession(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID, true, "", nil) +} + +// PrepareSessionWithWorkflowRoute creates a fresh session and records the +// prepared explicit-workflow destination in the same persistence transaction. +func (e *Executor) PrepareSessionWithWorkflowRoute( + ctx context.Context, + task *v1.Task, + agentProfileID, executorID, executorProfileID, workflowStepID string, + route *models.WorkflowSessionRoute, +) (string, error) { + return e.prepareSession(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID, true, "", route) } // PrepareSessionForExistingEnvironment creates a workflow replacement session @@ -846,7 +857,25 @@ func (e *Executor) PrepareSessionForExistingEnvironment(ctx context.Context, tas if err := e.preflightWorkflowWorkspaceReuse(ctx, task, taskEnvironmentID); err != nil { return "", err } - return e.prepareSession(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID, false, taskEnvironmentID) + return e.prepareSession(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID, false, taskEnvironmentID, nil) +} + +// PrepareSessionForExistingEnvironmentWithWorkflowRoute is the route-aware +// replacement-session path. The existing canonical environment is checked +// before the atomic session-plus-route insert. +func (e *Executor) PrepareSessionForExistingEnvironmentWithWorkflowRoute( + ctx context.Context, + task *v1.Task, + agentProfileID, executorID, executorProfileID, workflowStepID, taskEnvironmentID string, + route *models.WorkflowSessionRoute, +) (string, error) { + if taskEnvironmentID == "" { + return "", fmt.Errorf("%w: workflow replacement requires a canonical workspace", models.ErrWorkspaceReuseUnsafe) + } + if err := e.preflightWorkflowWorkspaceReuse(ctx, task, taskEnvironmentID); err != nil { + return "", err + } + return e.prepareSession(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID, false, taskEnvironmentID, route) } // preflightWorkflowWorkspaceReuse rejects an invalid retained environment @@ -898,7 +927,7 @@ func workflowEnvironmentHasRepository(rows []*models.TaskEnvironmentRepo, reposi } //nolint:cyclop,funlen // Session construction keeps its existing validation sequence in one transaction boundary. -func (e *Executor) prepareSession(ctx context.Context, task *v1.Task, agentProfileID string, executorID string, executorProfileID string, workflowStepID string, bindWorkspace bool, taskEnvironmentID string) (string, error) { +func (e *Executor) prepareSession(ctx context.Context, task *v1.Task, agentProfileID string, executorID string, executorProfileID string, workflowStepID string, bindWorkspace bool, taskEnvironmentID string, workflowRoute *models.WorkflowSessionRoute) (string, error) { if agentProfileID == "" { e.logger.Error("task has no agent_profile_id configured", zap.String("task_id", task.ID)) return "", ErrNoAgentProfileID @@ -1009,7 +1038,7 @@ func (e *Executor) prepareSession(ctx context.Context, task *v1.Task, agentProfi return "", err } - createErr := e.createPreparedSession(ctx, session, task.Metadata, bindWorkspace, execConfig) + createErr := e.createPreparedSession(ctx, session, task.Metadata, bindWorkspace, execConfig, workflowRoute) if createErr != nil { e.logger.Error("failed to persist agent session", zap.String("task_id", task.ID), @@ -1044,6 +1073,7 @@ func (e *Executor) createPreparedSession( metadata map[string]interface{}, bindWorkspace bool, execConfig executorConfig, + workflowRoute *models.WorkflowSessionRoute, ) error { candidate := &models.TaskEnvironment{ TaskID: session.TaskID, @@ -1053,6 +1083,12 @@ func (e *Executor) createPreparedSession( Status: models.TaskEnvironmentStatusCreating, } if groupID := sharedWorkspaceGroupID(metadata); bindWorkspace && groupID != "" { + if workflowRoute != nil { + if creator, ok := e.repo.(sharedGroupWorkspaceBindingWorkflowRouteTaskSessionCreator); ok { + workflowRoute.DestinationID = session.ID + return creator.CreateTaskSessionWithSharedGroupWorkspaceBindingAndWorkflowRoute(ctx, session, candidate, groupID, workflowRoute) + } + } binder, ok := e.repo.(sharedGroupWorkspaceBindingTaskSessionCreator) if !ok { return fmt.Errorf("%w: shared workspace binding is unavailable", models.ErrWorkspaceReuseUnsafe) @@ -1060,11 +1096,29 @@ func (e *Executor) createPreparedSession( return binder.CreateTaskSessionWithSharedGroupWorkspaceBinding(ctx, session, candidate, groupID) } if binder, ok := e.repo.(workspaceBindingTaskSessionCreator); ok && bindWorkspace && !taskUsesDeferredEnvironmentInheritance(metadata) { + if workflowRoute != nil { + if creator, routeOK := e.repo.(workspaceBindingWorkflowRouteTaskSessionCreator); routeOK { + workflowRoute.DestinationID = session.ID + return creator.CreateTaskSessionWithWorkspaceBindingAndWorkflowRoute(ctx, session, candidate, workflowRoute) + } + } return binder.CreateTaskSessionWithWorkspaceBinding(ctx, session, candidate) } if atomicCreator, ok := e.repo.(initialRuntimeSeedTaskSessionCreator); ok { + if workflowRoute != nil { + if creator, routeOK := e.repo.(initialRuntimeSeedWorkflowRouteTaskSessionCreator); routeOK { + workflowRoute.DestinationID = session.ID + return creator.CreateTaskSessionWithInitialRuntimeSeedAndWorkflowRoute(ctx, session, workflowRoute) + } + } return atomicCreator.CreateTaskSessionWithInitialRuntimeSeed(ctx, session) } + if workflowRoute != nil { + if creator, ok := e.repo.(workflowSessionRouteTaskSessionCreator); ok { + workflowRoute.DestinationID = session.ID + return creator.CreateTaskSessionWithWorkflowSessionRoute(ctx, session, workflowRoute) + } + } return e.repo.CreateTaskSession(ctx, session) } diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 442f72e913..e6fd255479 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -1056,6 +1056,9 @@ type startTaskOptions struct { // SpawnOrigin is set when another agent session spawned this launch; it // produces the spawner-attribution system block on the first turn. SpawnOrigin *SpawnOrigin + // WorkflowEntryID pins source bindings and explicit route retries to the + // immutable step-entry ledger row that initiated an automatic launch. + WorkflowEntryID int64 } // StartTaskWithRoute launches a stable Office identity through a complete @@ -1084,6 +1087,83 @@ func (s *Service) StartTaskWithRoute( return err } +func (s *Service) prepareExplicitWorkflowStartRoute( + ctx context.Context, + taskID string, + workflowSessionConfigStepID string, + entryIDs ...int64, +) (*models.TaskSession, string, *models.WorkflowSessionRoute, error) { + if workflowSessionConfigStepID == "" || s.workflowStepGetter == nil { + return nil, "", nil, nil + } + + candidateStep, err := s.workflowStepGetter.GetStep(ctx, workflowSessionConfigStepID) + if err != nil { + return nil, "", nil, fmt.Errorf("load workflow step for session target: %w", err) + } + if candidateStep == nil || candidateStep.SessionTarget == nil { + return nil, "", nil, nil + } + + selectedSession, profileID, err := s.selectExplicitWorkflowStartSession(ctx, taskID, candidateStep) + if err != nil { + return nil, "", nil, err + } + if s.profileExecutionResolver != nil { + if err := s.profileExecutionResolver.ValidateProfile(ctx, profileID); err != nil { + return nil, "", nil, err + } + } + + startPolicy := s.resolveStepProfileSessionStartPolicy(candidateStep) + route := &models.WorkflowSessionRoute{ + OperationID: workflowSessionRouteID(taskID, candidateStep.ID, s.workflowEntryIdentity(ctx, taskID, entryIDs...), candidateStep.SessionTarget, startPolicy), + DestinationStepID: candidateStep.ID, + TargetKind: string(candidateStep.SessionTarget.Kind), + TargetStepID: candidateStep.SessionTarget.StepID, + AgentProfileID: profileID, + Phase: workflowSessionRoutePrepared, + } + if recordedRoute, recordedSession, err := s.loadRecordedWorkflowSessionRoute(ctx, taskID, route.OperationID, candidateStep, profileID); err != nil { + return nil, "", nil, err + } else if recordedSession != nil { + selectedSession = recordedSession + route = recordedRoute + } + if selectedSession != nil || !s.supportsAtomicWorkflowSessionRouteCreation() { + if err := s.persistWorkflowSessionRoute(ctx, taskID, *route); err != nil { + return nil, "", nil, err + } + } + return selectedSession, profileID, route, nil +} + +func (s *Service) promoteSelectedExplicitWorkflowSession( + ctx context.Context, + taskID string, + session *models.TaskSession, + route *models.WorkflowSessionRoute, +) (string, bool, error) { + if err := s.preflightWorkflowSessionTarget(ctx, taskID, session); err != nil { + return "", false, err + } + var promoted bool + var err error + if route != nil { + promoted, err = s.promoteWorkflowSessionRoute(ctx, taskID, session, route) + } else { + promoted, err = s.setNonterminalSessionPrimary(ctx, session.ID) + } + if err != nil { + return "", false, fmt.Errorf("promote explicit workflow session: %w", err) + } + if !promoted { + return "", false, nil + } + s.tagSessionAsWorkflowSwitched(ctx, session.ID) + return session.ID, true, nil +} + //nolint:cyclop,funlen,gocognit // launch path threads many orthogonal concerns (workflow-step / agent-profile / office-task / config-mode / route / system-prompt wrapping); splitting it would require shared mutable state across helpers func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID string, executorID string, executorProfileID string, priority string, prompt string, workflowStepID string, planMode, autoStart bool, attachments []v1.MessageAttachment, opts startTaskOptions) (*executor.TaskExecution, error) { // One GetWorkflowMeta read shared by profile resolution and prompt build. @@ -1227,6 +1307,23 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s task.Priority = priority } + var explicitStartRoute *models.WorkflowSessionRoute + var selectedExplicitSession *models.TaskSession + var explicitProfileID string + selectedExplicitSession, explicitProfileID, explicitStartRoute, err = s.prepareExplicitWorkflowStartRoute( + ctx, + task.ID, + workflowSessionConfigStepID, + opts.WorkflowEntryID, + ) + if err != nil { + return nil, err + } + if explicitStartRoute != nil && explicitProfileID != "" { + agentProfileID = explicitProfileID + overrideApplied = agentProfileID != callerProfileID + } + // Use provided prompt, fall back to task description effectivePrompt := prompt if effectivePrompt == "" { @@ -1236,9 +1333,37 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s // Prepare session first so we have the sessionID for config context injection. // For office tasks, replace the per-launch PrepareSession with the per-(task, // agent) EnsureSessionForAgent so runs reuse one row across turns. - sessionID, sessionCreated, err := s.prepareSessionForStart(ctx, task, agentProfileID, officeAgentProfileID, executorID, executorProfileID, workflowStepID) - if err != nil { - return nil, err + var sessionID string + var sessionCreated bool + if selectedExplicitSession != nil { + var promoted bool + var promoteErr error + sessionID, promoted, promoteErr = s.promoteSelectedExplicitWorkflowSession(ctx, task.ID, selectedExplicitSession, explicitStartRoute) + if promoteErr != nil { + return nil, promoteErr + } + if !promoted { + selectedExplicitSession = nil + } + } + if sessionID == "" { + if explicitStartRoute != nil { + sessionID, sessionCreated, err = s.prepareSessionForStartWithWorkflowRoute(ctx, task, agentProfileID, officeAgentProfileID, executorID, executorProfileID, workflowStepID, explicitStartRoute) + } else { + sessionID, sessionCreated, err = s.prepareSessionForStart(ctx, task, agentProfileID, officeAgentProfileID, executorID, executorProfileID, workflowStepID) + } + if err != nil { + return nil, err + } + } + if explicitStartRoute != nil && selectedExplicitSession == nil { + promoted, promoteErr := s.promoteWorkflowSessionRoute(ctx, task.ID, &models.TaskSession{ID: sessionID}, explicitStartRoute) + if promoteErr != nil { + return nil, fmt.Errorf("promote explicit workflow start session: %w", promoteErr) + } + if !promoted { + return nil, fmt.Errorf("explicit workflow start session became terminal before promotion") + } } // Seed a matching conditional session configuration before lifecycle // startup. The ACP manager applies this durable runtime layer after the @@ -1276,6 +1401,15 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s if err != nil { return nil, fmt.Errorf("failed to reload launch session: %w", err) } + if explicitStartRoute == nil && workflowSessionConfigStepID != "" && s.workflowStepGetter != nil { + if sourceStep, stepErr := s.workflowStepGetter.GetStep(ctx, workflowSessionConfigStepID); stepErr != nil { + return nil, fmt.Errorf("load workflow source step for session binding: %w", stepErr) + } else if sourceStep != nil { + if bindErr := s.recordWorkflowSourceBinding(ctx, task.ID, sourceStep, launchSession, opts.WorkflowEntryID); bindErr != nil { + return nil, bindErr + } + } + } if route == nil { if agentProfileID, err = s.resolveDynamicLaunchExecution(ctx, launchSession, agentProfileID, false); err != nil { @@ -1592,7 +1726,15 @@ func (s *Service) prepareSessionForStart( ctx context.Context, task *v1.Task, agentProfileID, officeAgentProfileID, executorID, executorProfileID, workflowStepID string, ) (string, bool, error) { - sessionID, created, err := s.createStartSession(ctx, task, agentProfileID, officeAgentProfileID, executorID, executorProfileID, workflowStepID) + return s.prepareSessionForStartWithWorkflowRoute(ctx, task, agentProfileID, officeAgentProfileID, executorID, executorProfileID, workflowStepID, nil) +} + +func (s *Service) prepareSessionForStartWithWorkflowRoute( + ctx context.Context, task *v1.Task, + agentProfileID, officeAgentProfileID, executorID, executorProfileID, workflowStepID string, + workflowRoute *models.WorkflowSessionRoute, +) (string, bool, error) { + sessionID, created, err := s.createStartSessionWithWorkflowRoute(ctx, task, agentProfileID, officeAgentProfileID, executorID, executorProfileID, workflowStepID, workflowRoute) if err != nil { return "", false, err } @@ -1802,18 +1944,44 @@ func (s *Service) recordDynamicRouteResolutionFailure( func (s *Service) createStartSession( ctx context.Context, task *v1.Task, agentProfileID, officeAgentProfileID, executorID, executorProfileID, workflowStepID string, +) (string, bool, error) { + return s.createStartSessionWithWorkflowRoute(ctx, task, agentProfileID, officeAgentProfileID, executorID, executorProfileID, workflowStepID, nil) +} + +func (s *Service) createStartSessionWithWorkflowRoute( + ctx context.Context, task *v1.Task, + agentProfileID, officeAgentProfileID, executorID, executorProfileID, workflowStepID string, + workflowRoute *models.WorkflowSessionRoute, ) (string, bool, error) { dbTask, err := s.repo.GetTask(ctx, task.ID) if err != nil || dbTask == nil || !dbTask.IsFromOffice { - sessionID, prepareErr := s.executor.PrepareSession(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID) + var sessionID string + var prepareErr error + if workflowRoute != nil { + sessionID, prepareErr = s.executor.PrepareSessionWithWorkflowRoute(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID, workflowRoute) + } else { + sessionID, prepareErr = s.executor.PrepareSession(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID) + } return sessionID, prepareErr == nil, prepareErr } sessionOwnerID := s.officeSessionOwnerID(dbTask, agentProfileID, officeAgentProfileID) if sessionOwnerID == "" { - sessionID, prepareErr := s.executor.PrepareSession(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID) + var sessionID string + var prepareErr error + if workflowRoute != nil { + sessionID, prepareErr = s.executor.PrepareSessionWithWorkflowRoute(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID, workflowRoute) + } else { + sessionID, prepareErr = s.executor.PrepareSession(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID) + } return sessionID, prepareErr == nil, prepareErr } + if workflowRoute != nil { + // Office sessions are identity-owned and may already exist. The route + // promotion path handles an exact selected row; a fresh Office session + // is not created through the generic PrepareSession transaction. + return "", false, fmt.Errorf("workflow session routes are not supported for new office sessions") + } session, created, ensureErr := s.executor.EnsureSessionForAgentWithCreation( ctx, task, sessionOwnerID, agentProfileID, executorID, executorProfileID, ) @@ -3958,22 +4126,28 @@ func (s *Service) SetPrimarySession(ctx context.Context, sessionID string) error if err := s.repo.SetSessionPrimary(ctx, sessionID); err != nil { return fmt.Errorf("failed to set session as primary: %w", err) } + s.publishPrimarySessionUpdate(ctx, "", sessionID) + return nil +} +func (s *Service) publishPrimarySessionUpdate(ctx context.Context, taskID, sessionID string) { // Broadcast task.updated so frontend updates the primary star indicator. // The task service's publisher loads primary-session info from the DB, // which already reflects the SetSessionPrimary write above. session, err := s.repo.GetTaskSession(ctx, sessionID) if err != nil { s.logger.Warn("failed to fetch session after setting primary", zap.Error(err)) - return nil + return } - task, err := s.repo.GetTask(ctx, session.TaskID) + if taskID == "" { + taskID = session.TaskID + } + task, err := s.repo.GetTask(ctx, taskID) if err != nil { s.logger.Warn("failed to fetch task after setting primary", zap.Error(err)) - return nil + return } s.publishTaskUpdated(ctx, task) - return nil } // maxSessionNameLength caps user-supplied session names to keep tab labels sane. diff --git a/apps/backend/internal/orchestrator/task_operations_test.go b/apps/backend/internal/orchestrator/task_operations_test.go index 6ae9c6adc6..e77644be79 100644 --- a/apps/backend/internal/orchestrator/task_operations_test.go +++ b/apps/backend/internal/orchestrator/task_operations_test.go @@ -5400,6 +5400,45 @@ func TestStartTaskPublishesCreatedSessionBeforeLaunch(t *testing.T) { assert.True(t, publishedBeforeLaunch, "created session event must arrive before the runtime starts") } +func TestStartTaskRecordsDirectWorkflowSourceBindingBeforeLaunch(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-source-start", "existing-session", models.TaskSessionStateCompleted) + dbTask, err := repo.GetTask(ctx, "task-source-start") + require.NoError(t, err) + dbTask.WorkflowStepID = "step-implement" + require.NoError(t, repo.UpdateTask(ctx, dbTask)) + + stepGetter := newMockStepGetter() + stepGetter.steps["step-implement"] = &wfmodels.WorkflowStep{ + ID: "step-implement", WorkflowID: "wf1", AgentProfileID: "profile-implement", + } + taskRepo := newMockTaskRepo() + taskRepo.tasks["task-source-start"] = &v1.Task{ + ID: "task-source-start", Title: "Source start", Description: "Start here", State: v1.TaskStateInProgress, + } + bindingPresentBeforeLaunch := false + agentMgr := &mockAgentManager{ + repoForExecutionLookup: repo, + launchAgentFunc: func(_ context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + binding, bindingErr := repo.GetWorkflowSessionBinding(ctx, "task-source-start", workflowSessionBindingTargetKey("step-implement")) + bindingPresentBeforeLaunch = bindingErr == nil && binding != nil && binding.SessionID == req.SessionID + return &executor.LaunchAgentResponse{AgentExecutionID: "exec-" + req.SessionID}, nil + }, + } + svc := createTestServiceWithScheduler(repo, stepGetter, taskRepo, agentMgr) + + execution, err := svc.StartTask(ctx, "task-source-start", "profile-implement", "", "", "", "Start", "step-implement", false, false, nil) + require.NoError(t, err) + require.NotNil(t, execution) + require.True(t, bindingPresentBeforeLaunch) + + binding, err := repo.GetWorkflowSessionBinding(ctx, "task-source-start", workflowSessionBindingTargetKey("step-implement")) + require.NoError(t, err) + require.NotNil(t, binding) + require.Equal(t, execution.SessionID, binding.SessionID) +} + // TestStartTaskWithEnv_OfficeCreateThenReusePublishesOneCreatedEvent drives // two sequential office wakeups for the same (task, agent) pair through the // full StartTaskWithEnv path — not just the repository call-count proxy used diff --git a/apps/backend/internal/orchestrator/workflow_session_config.go b/apps/backend/internal/orchestrator/workflow_session_config.go index 8cd1bf5b24..703b0ac28c 100644 --- a/apps/backend/internal/orchestrator/workflow_session_config.go +++ b/apps/backend/internal/orchestrator/workflow_session_config.go @@ -436,10 +436,31 @@ func (s *Service) originalTaskSession(ctx context.Context, taskID string) (*mode if err != nil { return nil, err } + if task, taskErr := s.repo.GetTask(ctx, taskID); taskErr == nil && task != nil { + if snapshot, ok := models.LoadWorkflowInitialSessionSnapshot(task.Metadata); ok { + for _, candidate := range sessions { + if candidate != nil && candidate.ID == snapshot.SessionID { + return candidate, nil + } + } + return nil, nil + } + } if original, resolved := markedOriginalTaskSession(sessions); resolved { return original, nil } - return legacyOriginalTaskSession(sessions), nil + original := legacyOriginalTaskSession(sessions) + if original != nil { + if writer, ok := s.repo.(interface { + SetTaskMetadataKeyIfAbsent(context.Context, string, string, interface{}) (bool, error) + }); ok { + _, _ = writer.SetTaskMetadataKeyIfAbsent(ctx, taskID, models.MetaKeyWorkflowInitialSession, models.WorkflowInitialSessionSnapshot{ + SessionID: original.ID, + AgentProfileID: original.AgentProfileID, + }) + } + } + return original, nil } func markedOriginalTaskSession(sessions []*models.TaskSession) (*models.TaskSession, bool) { diff --git a/apps/backend/internal/orchestrator/workflow_session_target.go b/apps/backend/internal/orchestrator/workflow_session_target.go new file mode 100644 index 0000000000..64f12d2878 --- /dev/null +++ b/apps/backend/internal/orchestrator/workflow_session_target.go @@ -0,0 +1,543 @@ +package orchestrator + +import ( + "context" + "errors" + "fmt" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" +) + +const ( + workflowSessionRoutePrepared = "prepared" + workflowSessionRouteCommitted = "committed" +) + +type workflowSessionBindingStore interface { + GetWorkflowSessionBinding(context.Context, string, string) (*models.WorkflowSessionBinding, error) + UpsertWorkflowSessionBinding(context.Context, *models.WorkflowSessionBinding) (bool, error) +} + +type workflowSessionRouteCreator interface { + CreateTaskSessionWithWorkflowSessionRoute(context.Context, *models.TaskSession, *models.WorkflowSessionRoute) error +} + +type workflowSessionRoutePromoter interface { + SetSessionPrimaryWithWorkflowSessionRouteIfNonterminal(context.Context, string, models.WorkflowSessionRoute) (bool, error) +} + +type workflowStepTransitionReader interface { + GetLatestTaskStepTransitionID(context.Context, string) (int64, error) +} + +type workflowSessionTargetResolution struct { + session *models.TaskSession + profileID string +} + +func workflowSessionBindingTargetKey(stepID string) string { + return "step:" + stepID +} + +// resolveInitialWorkflowSession returns the exact immutable initial session +// and its original logical profile. A missing snapshot is resolved through +// originalTaskSession, which conservatively backfills older tasks only when +// provenance is unambiguous. +func (s *Service) resolveInitialWorkflowSession(ctx context.Context, taskID string) (*models.TaskSession, string, error) { + task, err := s.repo.GetTask(ctx, taskID) + if err != nil { + return nil, "", fmt.Errorf("load task for initial session target: %w", err) + } + if task == nil { + return nil, "", fmt.Errorf("task %s not found for initial session target", taskID) + } + sessions, err := s.repo.ListTaskSessions(ctx, taskID) + if err != nil { + return nil, "", fmt.Errorf("list sessions for initial session target: %w", err) + } + initial, err := s.originalTaskSession(ctx, taskID) + if err != nil { + return nil, "", err + } + profileID := "" + if snapshot, ok := models.LoadWorkflowInitialSessionSnapshot(task.Metadata); ok { + profileID = snapshot.AgentProfileID + } + if profileID == "" && initial != nil { + profileID = initial.AgentProfileID + } + if profileID == "" && len(sessions) == 0 { + profileID, _ = task.Metadata[models.MetaKeyAgentProfileID].(string) + } + if profileID == "" { + return nil, "", fmt.Errorf("initial session provenance is unavailable for task %s", taskID) + } + return initial, profileID, nil +} + +// resolveWorkflowSessionTarget resolves the exact recipient owned by a step. +// Profile-only lookup is intentionally not used here: explicit targets are +// either the immutable initial conversation or a source-step binding. +func (s *Service) resolveWorkflowSessionTarget( + ctx context.Context, + taskID string, + step *wfmodels.WorkflowStep, +) (workflowSessionTargetResolution, error) { + if step == nil || step.SessionTarget == nil { + return workflowSessionTargetResolution{}, fmt.Errorf("workflow session target is required") + } + if err := wfmodels.ValidateWorkflowSessionTarget(step.SessionTarget); err != nil { + return workflowSessionTargetResolution{}, err + } + if step.SessionTarget.Kind == wfmodels.WorkflowSessionTargetInitial { + session, profileID, err := s.resolveInitialWorkflowSession(ctx, taskID) + if err != nil { + return workflowSessionTargetResolution{}, err + } + return workflowSessionTargetResolution{session: session, profileID: profileID}, nil + } + + return s.resolveSourceWorkflowSessionTarget(ctx, taskID, step) +} + +func (s *Service) resolveSourceWorkflowSessionTarget( + ctx context.Context, + taskID string, + step *wfmodels.WorkflowStep, +) (workflowSessionTargetResolution, error) { + sourceStep, err := s.loadWorkflowStepForLifecycle(ctx, step.SessionTarget.StepID, "session target source") + if err != nil { + return workflowSessionTargetResolution{}, err + } + if err := validateWorkflowSessionTargetSource(step, sourceStep); err != nil { + return workflowSessionTargetResolution{}, err + } + session, err := s.resolveBoundSourceWorkflowSession(ctx, taskID, step.WorkflowID, sourceStep) + if err != nil { + return workflowSessionTargetResolution{}, err + } + return workflowSessionTargetResolution{session: session, profileID: sourceStep.AgentProfileID}, nil +} + +func validateWorkflowSessionTargetSource(destination, source *wfmodels.WorkflowStep) error { + if source.WorkflowID != destination.WorkflowID { + return fmt.Errorf("session target source step %q belongs to another workflow", source.ID) + } + if source.Position >= destination.Position { + return fmt.Errorf("session target source step %q is not earlier than destination step %q", source.ID, destination.ID) + } + if source.AgentProfileID == "" || source.SessionTarget != nil { + return fmt.Errorf("session target source step %q must use a direct agent profile", source.ID) + } + return nil +} + +func (s *Service) resolveBoundSourceWorkflowSession( + ctx context.Context, + taskID string, + workflowID string, + sourceStep *wfmodels.WorkflowStep, +) (*models.TaskSession, error) { + store, ok := s.repo.(workflowSessionBindingStore) + if !ok { + return nil, nil + } + binding, err := store.GetWorkflowSessionBinding(ctx, taskID, workflowSessionBindingTargetKey(sourceStep.ID)) + if err != nil { + return nil, err + } + if binding == nil || binding.TaskID != taskID || binding.WorkflowID != workflowID || + binding.AgentProfileID != sourceStep.AgentProfileID || binding.SessionID == "" { + return nil, nil + } + session, err := s.repo.GetTaskSession(ctx, binding.SessionID) + if err != nil { + return nil, fmt.Errorf("load session target binding %q: %w", sourceStep.ID, err) + } + if session == nil || session.TaskID != taskID || session.AgentProfileID != sourceStep.AgentProfileID { + return nil, nil + } + return session, nil +} + +func (s *Service) persistWorkflowSessionRoute(ctx context.Context, taskID string, route models.WorkflowSessionRoute) error { + setter, ok := s.repo.(taskMetadataKeySetter) + if !ok { + return nil + } + if err := setter.SetTaskMetadataKey(ctx, taskID, models.MetaKeyWorkflowSessionRoute, route); err != nil { + s.logger.Warn("failed to persist workflow session route", + zap.String("task_id", taskID), + zap.String("operation_id", route.OperationID), + zap.String("phase", route.Phase), + zap.Error(err), + ) + return fmt.Errorf("persist workflow session route: %w", err) + } + return nil +} + +func (s *Service) loadRecordedWorkflowSessionRoute( + ctx context.Context, + taskID string, + operationID string, + step *wfmodels.WorkflowStep, + profileID string, +) (*models.WorkflowSessionRoute, *models.TaskSession, error) { + task, err := s.repo.GetTask(ctx, taskID) + if err != nil { + return nil, nil, fmt.Errorf("load task for workflow session route: %w", err) + } + if task == nil { + return nil, nil, fmt.Errorf("task %s not found for workflow session route", taskID) + } + route, ok := models.LoadWorkflowSessionRoute(task.Metadata) + if !ok || route.OperationID != operationID || !workflowSessionRouteMatchesStep(&route, step, profileID) { + return nil, nil, nil + } + if route.DestinationID == "" || + (route.Phase != workflowSessionRoutePrepared && route.Phase != workflowSessionRouteCommitted) { + return &route, nil, nil + } + session, err := s.repo.GetTaskSession(ctx, route.DestinationID) + if err != nil { + return nil, nil, fmt.Errorf("load recorded workflow session route destination: %w", err) + } + if session == nil || session.TaskID != taskID || session.AgentProfileID != profileID { + return nil, nil, fmt.Errorf("recorded workflow session route destination %q is unavailable", route.DestinationID) + } + if isTerminalSessionState(session.State) { + // A terminal conversation is a valid historical destination, but it + // cannot satisfy reuse. Let the caller allocate the target profile's + // fresh fallback and replace this bounded route record. + return &route, nil, nil + } + return &route, session, nil +} + +func (s *Service) supportsAtomicWorkflowSessionRouteCreation() bool { + _, ok := s.repo.(workflowSessionRouteCreator) + return ok +} + +// promoteWorkflowSessionRoute combines destination promotion and route commit +// when the repository supports it. The compatibility path preserves the +// prepared route if a legacy adapter cannot provide the combined transaction. +func (s *Service) promoteWorkflowSessionRoute( + ctx context.Context, + taskID string, + destination *models.TaskSession, + route *models.WorkflowSessionRoute, +) (bool, error) { + if destination == nil { + return false, nil + } + if promoter, ok := s.repo.(workflowSessionRoutePromoter); ok && route != nil { + committed := *route + committed.DestinationID = destination.ID + committed.Phase = workflowSessionRouteCommitted + promoted, err := promoter.SetSessionPrimaryWithWorkflowSessionRouteIfNonterminal(ctx, destination.ID, committed) + if err != nil || !promoted { + return promoted, err + } + s.publishPrimarySessionUpdate(ctx, taskID, destination.ID) + return true, nil + } + promoted, err := s.setNonterminalSessionPrimary(ctx, destination.ID) + if err != nil || !promoted { + return promoted, err + } + if route != nil { + committed := *route + committed.DestinationID = destination.ID + committed.Phase = workflowSessionRouteCommitted + if err := s.persistWorkflowSessionRoute(ctx, taskID, committed); err != nil { + return false, err + } + } + s.publishPrimarySessionUpdate(ctx, taskID, destination.ID) + return true, nil +} + +func workflowSessionRouteMatchesStep(route *models.WorkflowSessionRoute, step *wfmodels.WorkflowStep, profileID string) bool { + if route == nil || step == nil || step.SessionTarget == nil { + return false + } + return route.DestinationStepID == step.ID && + route.TargetKind == string(step.SessionTarget.Kind) && + route.TargetStepID == step.SessionTarget.StepID && + route.AgentProfileID == profileID +} + +func (s *Service) reuseRecordedWorkflowSession( + ctx context.Context, + taskID string, + currentSession *models.TaskSession, + recordedRoute *models.WorkflowSessionRoute, + recordedSession *models.TaskSession, + endPolicy models.WorkflowProfileSessionEndPolicy, +) (*models.TaskSession, bool, error) { + if recordedRoute.Phase == workflowSessionRouteCommitted { + return recordedSession, recordedSession.ID != currentSession.ID, nil + } + if recordedSession.ID == currentSession.ID { + preparedRoute := *recordedRoute + preparedRoute.Phase = workflowSessionRoutePrepared + promoted, err := s.promoteWorkflowSessionRoute(ctx, taskID, currentSession, &preparedRoute) + if err != nil { + return nil, false, err + } + if !promoted { + return nil, false, errReusableSessionNoLongerActive + } + return currentSession, false, nil + } + + preparedRoute := *recordedRoute + preparedRoute.Phase = workflowSessionRoutePrepared + if err := s.persistWorkflowSessionRoute(ctx, taskID, preparedRoute); err != nil { + return nil, false, err + } + if err := s.preflightWorkflowSessionTarget(ctx, taskID, recordedSession); err != nil { + return nil, false, err + } + reused, err := s.reuseSessionForStepWithEndPolicy(ctx, taskID, currentSession, recordedSession, endPolicy, &preparedRoute) + if err == nil { + return reused, true, nil + } + if !errors.Is(err, errReusableSessionNoLongerActive) { + return nil, false, err + } + return nil, false, nil +} + +func workflowSessionRouteID(taskID, stepID, entryIdentity string, target *wfmodels.WorkflowSessionTarget, startPolicy models.WorkflowProfileSessionStartPolicy) string { + targetID := "" + targetKind := "" + if target != nil { + targetKind = string(target.Kind) + targetID = target.StepID + } + return fmt.Sprintf("workflow-session:%s:%s:%s:%s:%s:%s", taskID, stepID, entryIdentity, targetKind, targetID, startPolicy) +} + +// workflowEntryIdentity is the durable identity of one workflow-step entry. +// Transition deliveries carry the immutable ledger id directly. Direct/manual +// starts use the task's latest transition, while creation entries fall back to +// the task creation stamp. The identity must not depend on the current primary +// session because a retry can legitimately reload a different primary. +func (s *Service) workflowEntryIdentity(ctx context.Context, taskID string, entryIDs ...int64) string { + if len(entryIDs) > 0 && entryIDs[0] > 0 { + return fmt.Sprintf("entry:%020d", entryIDs[0]) + } + if reader, ok := s.repo.(workflowStepTransitionReader); ok { + if transitionID, err := reader.GetLatestTaskStepTransitionID(ctx, taskID); err == nil && transitionID > 0 { + return fmt.Sprintf("entry:%020d", transitionID) + } + } + task, err := s.repo.GetTask(ctx, taskID) + if err == nil && task != nil { + if task.WorkflowStepTransitionID > 0 { + return fmt.Sprintf("entry:%020d", task.WorkflowStepTransitionID) + } + if !task.CreatedAt.IsZero() { + return "created:" + task.CreatedAt.UTC().Format(time.RFC3339Nano) + } + if !task.UpdatedAt.IsZero() { + return "legacy:" + task.UpdatedAt.UTC().Format(time.RFC3339Nano) + } + } + return "legacy:unknown" +} + +func (s *Service) reuseResolvedWorkflowSession( + ctx context.Context, + taskID string, + currentSession *models.TaskSession, + targetSession *models.TaskSession, + baseRoute *models.WorkflowSessionRoute, + endPolicy models.WorkflowProfileSessionEndPolicy, +) (*models.TaskSession, bool, error) { + if targetSession == nil || isTerminalSessionState(targetSession.State) { + return nil, false, nil + } + if targetSession.ID == currentSession.ID { + return currentSession, false, nil + } + + baseRoute.DestinationID = targetSession.ID + baseRoute.Phase = workflowSessionRoutePrepared + if err := s.persistWorkflowSessionRoute(ctx, taskID, *baseRoute); err != nil { + return nil, false, err + } + if err := s.preflightWorkflowSessionTarget(ctx, taskID, targetSession); err != nil { + return nil, false, err + } + reused, err := s.reuseSessionForStepWithEndPolicy(ctx, taskID, currentSession, targetSession, endPolicy, baseRoute) + if errors.Is(err, errReusableSessionNoLongerActive) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return reused, true, nil +} + +func (s *Service) prepareExplicitWorkflowSession( + ctx context.Context, + taskID string, + currentSession *models.TaskSession, + step *wfmodels.WorkflowStep, + sourceStep *wfmodels.WorkflowStep, + entryIDs ...int64, +) (*models.TaskSession, bool, error) { + resolution, err := s.resolveWorkflowSessionTarget(ctx, taskID, step) + if err != nil { + return nil, false, err + } + targetSession, targetProfile := resolution.session, resolution.profileID + if targetProfile == "" { + return nil, false, fmt.Errorf("workflow session target has no logical agent profile") + } + startPolicy := s.resolveStepProfileSessionStartPolicy(step) + endPolicy := s.resolveStepProfileSessionEndPolicy(sourceStep) + entryIdentity := s.workflowEntryIdentity(ctx, taskID, entryIDs...) + operationID := workflowSessionRouteID(taskID, step.ID, entryIdentity, step.SessionTarget, startPolicy) + recordedRoute, recordedSession, err := s.loadRecordedWorkflowSessionRoute(ctx, taskID, operationID, step, targetProfile) + if err != nil { + return nil, false, err + } + if recordedSession != nil { + reused, switched, err := s.reuseRecordedWorkflowSession(ctx, taskID, currentSession, recordedRoute, recordedSession, endPolicy) + if err != nil || reused != nil { + return reused, switched, err + } + } + baseRoute := models.WorkflowSessionRoute{ + OperationID: operationID, + DestinationStepID: step.ID, + TargetKind: string(step.SessionTarget.Kind), + TargetStepID: step.SessionTarget.StepID, + AgentProfileID: targetProfile, + SourceSessionID: currentSession.ID, + } + + if startPolicy == models.WorkflowProfileSessionStartPolicyReuse { + reused, switched, err := s.reuseResolvedWorkflowSession(ctx, taskID, currentSession, targetSession, &baseRoute, endPolicy) + if err != nil || reused != nil { + return reused, switched, err + } + } + + baseRoute.Phase = workflowSessionRoutePrepared + if !s.supportsAtomicWorkflowSessionRouteCreation() { + if err := s.persistWorkflowSessionRoute(ctx, taskID, baseRoute); err != nil { + return nil, false, err + } + } + newSession, err := s.createNewSessionForStepWithEndPolicyAndRoute(ctx, taskID, currentSession, targetProfile, endPolicy, &baseRoute) + if err != nil { + return nil, false, err + } + return newSession, true, nil +} + +func (s *Service) recordWorkflowSourceBinding( + ctx context.Context, + taskID string, + step *wfmodels.WorkflowStep, + session *models.TaskSession, + entryIDs ...int64, +) error { + if step == nil || session == nil || step.AgentProfileID == "" || step.SessionTarget != nil { + return nil + } + store, ok := s.repo.(workflowSessionBindingStore) + if !ok { + return nil + } + task, err := s.repo.GetTask(ctx, taskID) + if err != nil { + return fmt.Errorf("load task before workflow source binding: %w", err) + } + if task == nil || (task.WorkflowStepID != "" && task.WorkflowStepID != step.ID) { + return nil + } + updatedAt := task.UpdatedAt + if updatedAt.IsZero() { + updatedAt = time.Now().UTC() + } + operationID := workflowSourceBindingOperationID( + s.workflowEntryIdentity(ctx, taskID, entryIDs...), + ) + accepted, err := store.UpsertWorkflowSessionBinding(ctx, &models.WorkflowSessionBinding{ + TaskID: taskID, + TargetKey: workflowSessionBindingTargetKey(step.ID), + WorkflowID: step.WorkflowID, + AgentProfileID: step.AgentProfileID, + SessionID: session.ID, + OperationID: operationID, + UpdatedAt: updatedAt, + }) + if err != nil { + s.logger.Error("failed to persist workflow source session binding", + zap.String("task_id", taskID), + zap.String("step_id", step.ID), + zap.String("session_id", session.ID), + zap.Error(err), + ) + return fmt.Errorf("persist workflow source session binding: %w", err) + } + if !accepted { + s.logger.Debug("ignored superseded workflow source session binding", + zap.String("task_id", taskID), + zap.String("step_id", step.ID), + zap.String("session_id", session.ID), + zap.String("operation_id", operationID)) + } + return nil +} + +func workflowSourceBindingOperationID(entryIdentity string) string { + return fmt.Sprintf("workflow-step-entry-v2:%s", entryIdentity) +} + +func (s *Service) preflightWorkflowSessionTarget(ctx context.Context, taskID string, target *models.TaskSession) error { + task, err := s.repo.GetTask(ctx, taskID) + if err != nil { + return fmt.Errorf("load task for explicit session target: %w", err) + } + if task == nil { + return fmt.Errorf("task %s not found for explicit session target", taskID) + } + return s.executor.PreflightManagedGitCredentials(ctx, task.WorkspaceID, taskID, target.ExecutorID, target.ExecutorProfileID) +} + +// selectExplicitWorkflowStartSession resolves a target for a launch that has +// no current primary session. It returns a reusable exact session when the +// destination asks for reuse; otherwise the caller creates a fresh session +// from profileID. +func (s *Service) selectExplicitWorkflowStartSession( + ctx context.Context, + taskID string, + step *wfmodels.WorkflowStep, +) (*models.TaskSession, string, error) { + resolution, err := s.resolveWorkflowSessionTarget(ctx, taskID, step) + if err != nil { + return nil, "", err + } + if resolution.profileID == "" { + return nil, "", fmt.Errorf("workflow session target has no logical agent profile") + } + if s.resolveStepProfileSessionStartPolicy(step) != models.WorkflowProfileSessionStartPolicyReuse || resolution.session == nil { + return nil, resolution.profileID, nil + } + if isTerminalSessionState(resolution.session.State) { + return nil, resolution.profileID, nil + } + return resolution.session, resolution.profileID, nil +} diff --git a/apps/backend/internal/orchestrator/workflow_session_target_test.go b/apps/backend/internal/orchestrator/workflow_session_target_test.go new file mode 100644 index 0000000000..64c2af625b --- /dev/null +++ b/apps/backend/internal/orchestrator/workflow_session_target_test.go @@ -0,0 +1,389 @@ +package orchestrator + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + "github.com/stretchr/testify/require" +) + +type failWorkflowRouteMetadataRepo struct { + repoStore + fail bool +} + +type revisitedWorkflowBindingBarrierRepo struct { + repoStore + bindingStore workflowSessionBindingStore + firstReady chan struct{} + releaseFirst chan struct{} + firstOnce sync.Once +} + +func (r *revisitedWorkflowBindingBarrierRepo) UpsertWorkflowSessionBinding( + ctx context.Context, + binding *models.WorkflowSessionBinding, +) (bool, error) { + if binding.OperationID == "workflow-step-entry-v2:entry:00000000000000000041" { + r.firstOnce.Do(func() { close(r.firstReady) }) + <-r.releaseFirst + } + return r.bindingStore.UpsertWorkflowSessionBinding(ctx, binding) +} + +func (r *revisitedWorkflowBindingBarrierRepo) GetWorkflowSessionBinding( + ctx context.Context, + taskID, targetKey string, +) (*models.WorkflowSessionBinding, error) { + return r.bindingStore.GetWorkflowSessionBinding(ctx, taskID, targetKey) +} + +func (r *failWorkflowRouteMetadataRepo) SetTaskMetadataKey(ctx context.Context, taskID, key string, value interface{}) error { + if r.fail && key == models.MetaKeyWorkflowSessionRoute { + return errors.New("workflow route metadata write failed") + } + return r.repoStore.SetTaskMetadataKey(ctx, taskID, key, value) +} + +func TestValidateWorkflowSessionTargetSource(t *testing.T) { + tests := []struct { + name string + dest *wfmodels.WorkflowStep + source *wfmodels.WorkflowStep + wantErr string + }{ + { + name: "valid earlier direct profile", + dest: &wfmodels.WorkflowStep{ID: "review", WorkflowID: "workflow", Position: 2}, + source: &wfmodels.WorkflowStep{ + ID: "implement", WorkflowID: "workflow", Position: 1, AgentProfileID: "profile-a", + }, + }, + { + name: "foreign workflow", + dest: &wfmodels.WorkflowStep{ID: "review", WorkflowID: "workflow", Position: 2}, + source: &wfmodels.WorkflowStep{ID: "implement", WorkflowID: "other", Position: 1, AgentProfileID: "profile-a"}, + wantErr: "belongs to another workflow", + }, + { + name: "later source", + dest: &wfmodels.WorkflowStep{ID: "review", WorkflowID: "workflow", Position: 1}, + source: &wfmodels.WorkflowStep{ID: "implement", WorkflowID: "workflow", Position: 2, AgentProfileID: "profile-a"}, + wantErr: "not earlier", + }, + { + name: "source without direct profile", + dest: &wfmodels.WorkflowStep{ID: "review", WorkflowID: "workflow", Position: 2}, + source: &wfmodels.WorkflowStep{ID: "implement", WorkflowID: "workflow", Position: 1}, + wantErr: "must use a direct agent profile", + }, + { + name: "indirect source", + dest: &wfmodels.WorkflowStep{ID: "review", WorkflowID: "workflow", Position: 2}, + source: &wfmodels.WorkflowStep{ + ID: "implement", WorkflowID: "workflow", Position: 1, AgentProfileID: "profile-a", + SessionTarget: &wfmodels.WorkflowSessionTarget{Kind: wfmodels.WorkflowSessionTargetInitial}, + }, + wantErr: "must use a direct agent profile", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateWorkflowSessionTargetSource(tt.dest, tt.source) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestSelectExplicitWorkflowStartSessionFallsBackFromTerminalTarget(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + terminal := fixture.current + terminal.State = models.TaskSessionStateCompleted + require.NoError(t, fixture.repo.UpdateTaskSession(ctx, terminal)) + + step := &wfmodels.WorkflowStep{ + ID: "step-review", WorkflowID: "wf1", Position: 1, + SessionTarget: &wfmodels.WorkflowSessionTarget{Kind: wfmodels.WorkflowSessionTargetInitial}, + ProfileSessionStartPolicy: models.WorkflowProfileSessionStartPolicyReuse, + } + selected, profileID, err := fixture.svc.selectExplicitWorkflowStartSession(ctx, "t1", step) + + require.NoError(t, err) + require.Nil(t, selected) + require.Equal(t, "profile-a", profileID) +} + +func TestRecordWorkflowSourceBindingIgnoresDelayedEntryAfterTaskMoves(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + source := &wfmodels.WorkflowStep{ + ID: "step-a", WorkflowID: "wf1", AgentProfileID: "profile-a", + } + require.NoError(t, fixture.svc.recordWorkflowSourceBinding(ctx, "t1", source, fixture.current)) + + task, err := fixture.repo.GetTask(ctx, "t1") + require.NoError(t, err) + task.WorkflowStepID = "step-b" + require.NoError(t, fixture.repo.UpdateTask(ctx, task)) + + stale := &models.TaskSession{ID: "stale-session", TaskID: "t1", AgentProfileID: "profile-a"} + require.NoError(t, fixture.svc.recordWorkflowSourceBinding(ctx, "t1", source, stale)) + + binding, err := fixture.repo.GetWorkflowSessionBinding(ctx, "t1", workflowSessionBindingTargetKey(source.ID)) + require.NoError(t, err) + require.Equal(t, fixture.current.ID, binding.SessionID) +} + +func TestRecordWorkflowSourceBindingDoesNotOverwriteRevisitedSourceEntry(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + source := &wfmodels.WorkflowStep{ + ID: "step-a", WorkflowID: "wf1", AgentProfileID: "profile-a", + } + second := &models.TaskSession{ID: "session-a-revisit", TaskID: "t1", AgentProfileID: "profile-a"} + require.NoError(t, fixture.repo.CreateTaskSession(ctx, second)) + + require.NoError(t, fixture.svc.recordWorkflowSourceBinding(ctx, "t1", source, fixture.current, 41)) + require.NoError(t, fixture.svc.recordWorkflowSourceBinding(ctx, "t1", source, second, 42)) + // E1 resumes after E2 has already committed while the task is back on the + // same source step. Its current-step guard alone is insufficient; the + // immutable entry operation must lose the conditional write. + require.NoError(t, fixture.svc.recordWorkflowSourceBinding(ctx, "t1", source, fixture.current, 41)) + + binding, err := fixture.repo.GetWorkflowSessionBinding(ctx, "t1", workflowSessionBindingTargetKey(source.ID)) + require.NoError(t, err) + require.Equal(t, second.ID, binding.SessionID) + require.Equal(t, "workflow-step-entry-v2:entry:00000000000000000042", binding.OperationID) +} + +func TestRevisitedSourceBindingBarrierPreservesLaterTargetSelection(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + source := &wfmodels.WorkflowStep{ + ID: "step-a", WorkflowID: "wf1", Position: 0, AgentProfileID: "profile-a", + } + target := &wfmodels.WorkflowStep{ + ID: "step-review", WorkflowID: "wf1", Position: 1, + SessionTarget: &wfmodels.WorkflowSessionTarget{Kind: wfmodels.WorkflowSessionTargetStep, StepID: source.ID}, + } + fixture.stepGetter.steps[source.ID] = source + second := &models.TaskSession{ID: "session-a-revisit-barrier", TaskID: "t1", AgentProfileID: "profile-a"} + require.NoError(t, fixture.repo.CreateTaskSession(ctx, second)) + + barrierRepo := &revisitedWorkflowBindingBarrierRepo{ + repoStore: fixture.repo, + bindingStore: fixture.repo, + firstReady: make(chan struct{}), + releaseFirst: make(chan struct{}), + } + fixture.svc.repo = barrierRepo + firstErr := make(chan error, 1) + go func() { + firstErr <- fixture.svc.recordWorkflowSourceBinding(ctx, "t1", source, fixture.current, 41) + }() + <-barrierRepo.firstReady + secondErr := fixture.svc.recordWorkflowSourceBinding(ctx, "t1", source, second, 42) + close(barrierRepo.releaseFirst) + require.NoError(t, secondErr) + require.NoError(t, <-firstErr) + + resolution, err := fixture.svc.resolveWorkflowSessionTarget(ctx, "t1", target) + require.NoError(t, err) + require.Equal(t, second.ID, resolution.session.ID) +} + +func TestLoadRecordedWorkflowSessionRouteReturnsCommittedDestination(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + target := &wfmodels.WorkflowSessionTarget{Kind: wfmodels.WorkflowSessionTargetStep, StepID: "step-a"} + step := &wfmodels.WorkflowStep{ + ID: "step-review", WorkflowID: "wf1", Position: 1, SessionTarget: target, + } + operationID := workflowSessionRouteID("t1", step.ID, "", target, models.WorkflowProfileSessionStartPolicyReuse) + require.NoError(t, fixture.repo.SetTaskMetadataKey(ctx, "t1", models.MetaKeyWorkflowSessionRoute, models.WorkflowSessionRoute{ + OperationID: operationID, + DestinationStepID: step.ID, + TargetKind: string(target.Kind), + TargetStepID: target.StepID, + AgentProfileID: "profile-a", + DestinationID: fixture.current.ID, + Phase: workflowSessionRouteCommitted, + })) + + route, session, err := fixture.svc.loadRecordedWorkflowSessionRoute(ctx, "t1", operationID, step, "profile-a") + + require.NoError(t, err) + require.NotNil(t, route) + require.Equal(t, workflowSessionRouteCommitted, route.Phase) + require.NotNil(t, session) + require.Equal(t, fixture.current.ID, session.ID) +} + +func TestLoadRecordedWorkflowSessionRouteRejectsDeletedDestination(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + target := &wfmodels.WorkflowSessionTarget{Kind: wfmodels.WorkflowSessionTargetInitial} + step := &wfmodels.WorkflowStep{ID: "step-review", WorkflowID: "wf1", Position: 1, SessionTarget: target} + operationID := workflowSessionRouteID("t1", step.ID, "entry:00000000000000000007", target, models.WorkflowProfileSessionStartPolicyReuse) + require.NoError(t, fixture.repo.SetTaskMetadataKey(ctx, "t1", models.MetaKeyWorkflowSessionRoute, models.WorkflowSessionRoute{ + OperationID: operationID, + DestinationStepID: step.ID, + TargetKind: string(target.Kind), + AgentProfileID: "profile-a", + DestinationID: "deleted-destination", + Phase: workflowSessionRoutePrepared, + })) + + _, _, err := fixture.svc.loadRecordedWorkflowSessionRoute(ctx, "t1", operationID, step, "profile-a") + require.ErrorContains(t, err, "recorded workflow session route destination") +} + +func TestLoadRecordedWorkflowSessionRouteAllowsFreshFallbackFromTerminalDestination(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + fixture.current.State = models.TaskSessionStateCompleted + require.NoError(t, fixture.repo.UpdateTaskSession(ctx, fixture.current)) + target := &wfmodels.WorkflowSessionTarget{Kind: wfmodels.WorkflowSessionTargetInitial} + step := &wfmodels.WorkflowStep{ID: "step-review", WorkflowID: "wf1", Position: 1, SessionTarget: target} + operationID := workflowSessionRouteID("t1", step.ID, "entry:00000000000000000008", target, models.WorkflowProfileSessionStartPolicyReuse) + require.NoError(t, fixture.repo.SetTaskMetadataKey(ctx, "t1", models.MetaKeyWorkflowSessionRoute, models.WorkflowSessionRoute{ + OperationID: operationID, + DestinationStepID: step.ID, + TargetKind: string(target.Kind), + AgentProfileID: "profile-a", + DestinationID: fixture.current.ID, + Phase: workflowSessionRouteCommitted, + })) + + route, session, err := fixture.svc.loadRecordedWorkflowSessionRoute(ctx, "t1", operationID, step, "profile-a") + require.NoError(t, err) + require.NotNil(t, route) + require.Nil(t, session) +} + +func TestCreateTaskSessionWithWorkflowRoutePersistsPreparedDestinationAtomically(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + route := &models.WorkflowSessionRoute{ + OperationID: "workflow-session:t1:step-review:entry:00000000000000000009:initial:", + DestinationStepID: "step-review", + TargetKind: string(wfmodels.WorkflowSessionTargetInitial), + AgentProfileID: "profile-a", + Phase: workflowSessionRoutePrepared, + } + destination := &models.TaskSession{ + ID: "workflow-route-destination", + TaskID: "t1", + AgentProfileID: "profile-a", + State: models.TaskSessionStateCreated, + } + require.NoError(t, fixture.repo.CreateTaskSessionWithWorkflowSessionRoute(ctx, destination, route)) + + task, err := fixture.repo.GetTask(ctx, "t1") + require.NoError(t, err) + recorded, ok := models.LoadWorkflowSessionRoute(task.Metadata) + require.True(t, ok) + require.Equal(t, destination.ID, recorded.DestinationID) + require.Equal(t, workflowSessionRoutePrepared, recorded.Phase) +} + +func TestReusePreparedWorkflowRouteCommitsWhenDestinationIsAlreadyPrimary(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + target := &wfmodels.WorkflowSessionTarget{Kind: wfmodels.WorkflowSessionTargetInitial} + step := &wfmodels.WorkflowStep{ID: "step-review", WorkflowID: "wf1", Position: 1, SessionTarget: target} + route := models.WorkflowSessionRoute{ + OperationID: workflowSessionRouteID("t1", step.ID, "entry:00000000000000000011", target, models.WorkflowProfileSessionStartPolicyReuse), + DestinationStepID: step.ID, + TargetKind: string(target.Kind), + AgentProfileID: "profile-a", + DestinationID: fixture.current.ID, + Phase: workflowSessionRoutePrepared, + } + require.NoError(t, fixture.repo.SetTaskMetadataKey(ctx, "t1", models.MetaKeyWorkflowSessionRoute, route)) + + reused, switched, err := fixture.svc.reuseRecordedWorkflowSession(ctx, "t1", fixture.current, &route, fixture.current, models.WorkflowProfileSessionEndPolicyPark) + require.NoError(t, err) + require.False(t, switched) + require.Equal(t, fixture.current.ID, reused.ID) + + task, err := fixture.repo.GetTask(ctx, "t1") + require.NoError(t, err) + committed, ok := models.LoadWorkflowSessionRoute(task.Metadata) + require.True(t, ok) + require.Equal(t, workflowSessionRouteCommitted, committed.Phase) +} + +func TestWorkflowRouteRetryRecoversAfterLegacyCommitMetadataFailure(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + destination := &models.TaskSession{ + ID: "workflow-route-retry-destination", + TaskID: "t1", + AgentProfileID: "profile-a", + State: models.TaskSessionStateCreated, + } + require.NoError(t, fixture.repo.CreateTaskSession(ctx, destination)) + target := &wfmodels.WorkflowSessionTarget{Kind: wfmodels.WorkflowSessionTargetInitial} + route := models.WorkflowSessionRoute{ + OperationID: workflowSessionRouteID("t1", "step-review", "entry:00000000000000000013", target, models.WorkflowProfileSessionStartPolicyReuse), + DestinationStepID: "step-review", + TargetKind: string(target.Kind), + AgentProfileID: "profile-a", + DestinationID: destination.ID, + Phase: workflowSessionRoutePrepared, + } + require.NoError(t, fixture.repo.SetTaskMetadataKey(ctx, "t1", models.MetaKeyWorkflowSessionRoute, route)) + + failedRepo := &failWorkflowRouteMetadataRepo{repoStore: fixture.repo, fail: true} + fixture.svc.repo = failedRepo + _, err := fixture.svc.promoteWorkflowSessionRoute(ctx, "t1", destination, &route) + require.ErrorContains(t, err, "workflow route metadata write failed") + + promoted, err := fixture.repo.GetTaskSession(ctx, destination.ID) + require.NoError(t, err) + require.True(t, promoted.IsPrimary) + require.Equal(t, models.TaskSessionStateRunning, fixtureSessionState(t, fixture.repo, fixture.current.ID)) + + // A retry after reloading the primary must use the prepared destination, + // commit the existing route, and never allocate another session. + fixture.svc.repo = fixture.repo + recordedTask, err := fixture.repo.GetTask(ctx, "t1") + require.NoError(t, err) + recordedRoute, ok := models.LoadWorkflowSessionRoute(recordedTask.Metadata) + require.True(t, ok) + reused, switched, err := fixture.svc.reuseRecordedWorkflowSession( + ctx, "t1", fixture.current, &recordedRoute, promoted, models.WorkflowProfileSessionEndPolicyPark, + ) + require.NoError(t, err) + require.True(t, switched) + require.Equal(t, destination.ID, reused.ID) + + sessions, err := fixture.repo.ListTaskSessions(ctx, "t1") + require.NoError(t, err) + require.Len(t, sessions, 2) + reloadedTask, err := fixture.repo.GetTask(ctx, "t1") + require.NoError(t, err) + committed, ok := models.LoadWorkflowSessionRoute(reloadedTask.Metadata) + require.True(t, ok) + require.Equal(t, workflowSessionRouteCommitted, committed.Phase) +} + +func fixtureSessionState(t *testing.T, repo interface { + GetTaskSession(context.Context, string) (*models.TaskSession, error) +}, sessionID string) models.TaskSessionState { + t.Helper() + session, err := repo.GetTaskSession(context.Background(), sessionID) + require.NoError(t, err) + return session.State +} diff --git a/apps/backend/internal/persistence/requiredstores/catalog.go b/apps/backend/internal/persistence/requiredstores/catalog.go index b3744a37fb..c4dc43dc29 100644 --- a/apps/backend/internal/persistence/requiredstores/catalog.go +++ b/apps/backend/internal/persistence/requiredstores/catalog.go @@ -40,7 +40,7 @@ type Descriptor struct { // of the database contract and must be initialized before readiness. var catalog = []Descriptor{ {ID: "schema-meta", OwnerPackage: "internal/persistence", RequiredTables: []string{"kandev_meta"}}, - {ID: "task", OwnerPackage: "internal/task/repository/sqlite", RequiredTables: []string{"workspaces", "tasks"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, + {ID: "task", OwnerPackage: "internal/task/repository/sqlite", RequiredTables: []string{"workspaces", "tasks", "task_workflow_session_bindings"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, {ID: "workflow", OwnerPackage: "internal/workflow/repository", RequiredTables: []string{"workflow_templates", "workflow_steps"}, DependsOn: []string{"task"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, {ID: "analytics", OwnerPackage: "internal/analytics/repository", RequiredTables: []string{"tasks"}, DependsOn: []string{"task", "workflow"}, Capabilities: []Capability{CapabilityTimestamp}}, {ID: "agent-settings", OwnerPackage: "internal/agent/settings/store", RequiredTables: []string{"agents", "agent_profiles"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, diff --git a/apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/manifest.json b/apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/manifest.json index eed07fa838..3a86cffd5a 100644 --- a/apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/manifest.json +++ b/apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/manifest.json @@ -68,7 +68,8 @@ } ], "known_missing_required_stores": [ - "system-settings", + "task", + "system-settings", "auth-hostnames", "organizations", "organization-units", diff --git a/apps/backend/internal/persistence/storeconformance/upgrade_test.go b/apps/backend/internal/persistence/storeconformance/upgrade_test.go index 91728b4d92..9f38dff4d1 100644 --- a/apps/backend/internal/persistence/storeconformance/upgrade_test.go +++ b/apps/backend/internal/persistence/storeconformance/upgrade_test.go @@ -129,16 +129,19 @@ func validateOwnerRowCoverage(fixture upgradeFixture, knownMissing []string) err return fmt.Errorf("owner row names unknown catalog store %q", row.Owner) } if _, ok := missing[row.Owner]; ok { - return fmt.Errorf("owner row exists for known-missing store %q", row.Owner) + // A stable fixture can contain rows for a store whose current + // descriptor gained an additional required table. The inventory check + // below still proves that the older fixture lacks that table. + continue } present[row.Owner] = true } for _, descriptor := range requiredstores.Catalog() { _, expectedMissing := missing[descriptor.ID] + if expectedMissing { + continue + } if present[descriptor.ID] == expectedMissing { - if expectedMissing { - return fmt.Errorf("known-missing store %q has an owner row", descriptor.ID) - } return fmt.Errorf("present store %q has no owner row", descriptor.ID) } } diff --git a/apps/backend/internal/task/dto/converters.go b/apps/backend/internal/task/dto/converters.go index 927cdb2c0f..8fc90c63c9 100644 --- a/apps/backend/internal/task/dto/converters.go +++ b/apps/backend/internal/task/dto/converters.go @@ -23,6 +23,7 @@ func FromWorkflowStep(step *wfmodels.WorkflowStep) WorkflowStepDTO { AgentProfileID: step.AgentProfileID, ProfileSessionStartPolicy: models.NormalizeWorkflowProfileSessionStartPolicy(string(step.ProfileSessionStartPolicy)), ProfileSessionEndPolicy: models.NormalizeWorkflowProfileSessionEndPolicy(string(step.ProfileSessionEndPolicy)), + SessionTarget: wfmodels.CloneWorkflowSessionTarget(step.SessionTarget), WIPLimit: step.WIPLimit, PullFromStepID: step.PullFromStepID, StageType: string(step.StageType), diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 89618c2ea8..d92c16da66 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -7,6 +7,7 @@ import ( "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/service" "github.com/kandev/kandev/internal/task/statussummary" + wfmodels "github.com/kandev/kandev/internal/workflow/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -1173,6 +1174,7 @@ type WorkflowStepDTO struct { AgentProfileID string `json:"agent_profile_id,omitempty"` ProfileSessionStartPolicy models.WorkflowProfileSessionStartPolicy `json:"profile_session_start_policy"` ProfileSessionEndPolicy models.WorkflowProfileSessionEndPolicy `json:"profile_session_end_policy"` + SessionTarget *wfmodels.WorkflowSessionTarget `json:"session_target,omitempty"` WIPLimit int `json:"wip_limit"` PullFromStepID string `json:"pull_from_step_id,omitempty"` // StageType is a Phase 2 (ADR-0004) semantic hint for the frontend. diff --git a/apps/backend/internal/task/dto/workflow_profile_session_policy_test.go b/apps/backend/internal/task/dto/workflow_profile_session_policy_test.go index 28a3e913f6..ff80bca11a 100644 --- a/apps/backend/internal/task/dto/workflow_profile_session_policy_test.go +++ b/apps/backend/internal/task/dto/workflow_profile_session_policy_test.go @@ -31,7 +31,7 @@ func TestFromWorkflowStepIncludesProfileSessionPolicies(t *testing.T) { func TestFromWorkflowStepDefaultsMissingProfileSessionPolicies(t *testing.T) { step := FromWorkflowStep(&wfmodels.WorkflowStep{ID: "step-1"}) - if step.ProfileSessionStartPolicy != "reuse" || step.ProfileSessionEndPolicy != "complete" { - t.Fatalf("profile session policies = %q/%q, want reuse/complete", step.ProfileSessionStartPolicy, step.ProfileSessionEndPolicy) + if step.ProfileSessionStartPolicy != "reuse" || step.ProfileSessionEndPolicy != "park" { + t.Fatalf("profile session policies = %q/%q, want reuse/park", step.ProfileSessionStartPolicy, step.ProfileSessionEndPolicy) } } diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 221e0b25d3..817dd443fb 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -102,6 +102,12 @@ const ( MetaKeyAutomationTaskMode = "automation_task_mode" MetaKeyAutomationRepositoryMode = "automation_repository_mode" MetaKeyDeferredLaunch = "deferred_launch" + // MetaKeyWorkflowInitialSession is a write-once task-local snapshot of + // the first session identity used by workflow session targeting. + MetaKeyWorkflowInitialSession = "workflow_initial_session" + // MetaKeyWorkflowSessionRoute stores the bounded prepared/committed route + // identity used to retry an explicit workflow session selection. + MetaKeyWorkflowSessionRoute = "workflow_session_route" // MetaKeyQueuedMoveExitPending identifies a queued manual move whose // source-step on_exit side effect is not yet complete. Its value records the // source step so recovery can resume the work after a restart. @@ -223,6 +229,83 @@ const ( MetaKeyStepHandoffCarry = "step_handoff_carry" ) +// WorkflowInitialSessionSnapshot identifies the immutable task-initial +// conversation and the logical profile selected when it was created. +type WorkflowInitialSessionSnapshot struct { + SessionID string `json:"session_id"` + AgentProfileID string `json:"agent_profile_id,omitempty"` +} + +// WorkflowSessionRoute records one in-flight or committed explicit recipient +// selection. It is replaced by the next route operation rather than growing a +// task history. +type WorkflowSessionRoute struct { + OperationID string `json:"operation_id"` + DestinationStepID string `json:"destination_step_id"` + TargetKind string `json:"target_kind"` + TargetStepID string `json:"target_step_id,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + SourceSessionID string `json:"source_session_id,omitempty"` + DestinationID string `json:"destination_session_id,omitempty"` + Phase string `json:"phase"` +} + +// LoadWorkflowSessionRoute decodes the bounded route record stored in task +// metadata. Invalid or incomplete values are ignored so a stale record cannot +// redirect a workflow entry to an unrelated session. +func LoadWorkflowSessionRoute(metadata map[string]interface{}) (WorkflowSessionRoute, bool) { + value, ok := metadata[MetaKeyWorkflowSessionRoute] + if !ok { + return WorkflowSessionRoute{}, false + } + payload, err := json.Marshal(value) + if err != nil { + return WorkflowSessionRoute{}, false + } + var route WorkflowSessionRoute + if err := json.Unmarshal(payload, &route); err != nil { + return WorkflowSessionRoute{}, false + } + if route.OperationID == "" || route.DestinationStepID == "" || route.TargetKind == "" || route.Phase == "" { + return WorkflowSessionRoute{}, false + } + return route, true +} + +// WorkflowSessionBinding records the latest session selected when a direct +// profile workflow step was entered. The session pointer is nullable because +// deleting a session must preserve the logical profile for a later fresh +// fallback. +type WorkflowSessionBinding struct { + TaskID string `json:"task_id"` + TargetKey string `json:"target_key"` + WorkflowID string `json:"workflow_id"` + AgentProfileID string `json:"agent_profile_id"` + SessionID string `json:"session_id,omitempty"` + OperationID string `json:"operation_id"` + UpdatedAt time.Time `json:"updated_at"` +} + +// LoadWorkflowInitialSessionSnapshot decodes the write-once task metadata +// value. Invalid or incomplete values are treated as unavailable so callers +// can apply the conservative legacy fallback. +func LoadWorkflowInitialSessionSnapshot(metadata map[string]interface{}) (WorkflowInitialSessionSnapshot, bool) { + value, ok := metadata[MetaKeyWorkflowInitialSession] + if !ok { + return WorkflowInitialSessionSnapshot{}, false + } + object, ok := value.(map[string]interface{}) + if !ok { + return WorkflowInitialSessionSnapshot{}, false + } + sessionID, _ := object["session_id"].(string) + profileID, _ := object["agent_profile_id"].(string) + if sessionID == "" { + return WorkflowInitialSessionSnapshot{}, false + } + return WorkflowInitialSessionSnapshot{SessionID: sessionID, AgentProfileID: profileID}, true +} + // StepHandoffCarryToken is the JSON shape stored under // tasks.metadata[MetaKeyStepHandoffCarry]. Stamp is a fresh unique value // (uuid.NewString()) minted on every write, compared by the claim's @@ -1100,10 +1183,10 @@ const ( // and unknown workflow step session-end policy values. func NormalizeWorkflowProfileSessionEndPolicy(value string) WorkflowProfileSessionEndPolicy { value = strings.TrimSpace(value) - if WorkflowProfileSessionEndPolicy(value) == WorkflowProfileSessionEndPolicyPark { - return WorkflowProfileSessionEndPolicyPark + if WorkflowProfileSessionEndPolicy(value) == WorkflowProfileSessionEndPolicyComplete { + return WorkflowProfileSessionEndPolicyComplete } - return WorkflowProfileSessionEndPolicyComplete + return WorkflowProfileSessionEndPolicyPark } // WorkflowSource values are persisted in workflows.source and record where a diff --git a/apps/backend/internal/task/models/workflow_profile_session_policy_test.go b/apps/backend/internal/task/models/workflow_profile_session_policy_test.go index f1ab17de15..6752d8207b 100644 --- a/apps/backend/internal/task/models/workflow_profile_session_policy_test.go +++ b/apps/backend/internal/task/models/workflow_profile_session_policy_test.go @@ -29,8 +29,8 @@ func TestNormalizeWorkflowProfileSessionEndPolicy(t *testing.T) { value string want WorkflowProfileSessionEndPolicy }{ - {name: "empty defaults to complete", value: "", want: WorkflowProfileSessionEndPolicyComplete}, - {name: "unknown defaults to complete", value: "retain", want: WorkflowProfileSessionEndPolicyComplete}, + {name: "empty defaults to park", value: "", want: WorkflowProfileSessionEndPolicyPark}, + {name: "unknown defaults to park", value: "retain", want: WorkflowProfileSessionEndPolicyPark}, {name: "canonical value is preserved", value: "complete", want: WorkflowProfileSessionEndPolicyComplete}, {name: "known value is canonicalized", value: " park ", want: WorkflowProfileSessionEndPolicyPark}, } diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index 958c04abd6..7c79c494dc 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -105,6 +105,9 @@ func (r *Repository) runMigrations() error { r.migrate.Apply("task_sessions.base_commit_sha", `ALTER TABLE task_sessions ADD COLUMN base_commit_sha TEXT DEFAULT ''`) r.migrate.Apply("workspaces.default_config_agent_profile_id", `ALTER TABLE workspaces ADD COLUMN default_config_agent_profile_id TEXT DEFAULT ''`) r.migrate.Apply("task_sessions.task_environment_id", `ALTER TABLE task_sessions ADD COLUMN task_environment_id TEXT DEFAULT ''`) + if err := r.migrateWorkflowSessionBindings(); err != nil { + return err + } r.migrate.Apply("tasks.parent_id", `ALTER TABLE tasks ADD COLUMN parent_id TEXT DEFAULT ''`) r.migrate.Apply("tasks.autopilot_enabled", `ALTER TABLE tasks ADD COLUMN autopilot_enabled INTEGER NOT NULL DEFAULT 0`) // Remove FK constraint on workflow_id to allow ephemeral tasks without workflows @@ -309,7 +312,8 @@ func (r *Repository) runMigrations() error { r.migrate.Apply("workflow_steps.cancel_triggers_turn_complete", `ALTER TABLE workflow_steps ADD COLUMN cancel_triggers_turn_complete INTEGER NOT NULL DEFAULT 0`) _ = r.migrate.Apply("workflow_steps.complete_task_on_enter", `ALTER TABLE workflow_steps ADD COLUMN complete_task_on_enter INTEGER NOT NULL DEFAULT 0`) r.migrate.Apply("workflow_steps.profile_session_start_policy", `ALTER TABLE workflow_steps ADD COLUMN profile_session_start_policy TEXT NOT NULL DEFAULT 'reuse'`) - r.migrate.Apply("workflow_steps.profile_session_end_policy", `ALTER TABLE workflow_steps ADD COLUMN profile_session_end_policy TEXT NOT NULL DEFAULT 'complete'`) + _ = r.migrate.Apply("workflow_steps.profile_session_end_policy", `ALTER TABLE workflow_steps ADD COLUMN profile_session_end_policy TEXT NOT NULL DEFAULT 'park'`) + _ = r.migrate.Apply("workflow_steps.session_target", `ALTER TABLE workflow_steps ADD COLUMN session_target TEXT`) // Slack-style unread divider: the read cursor a session advances to the // latest message id whenever it becomes the visible chat panel. The diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index 3fa212926a..2a15f0bee3 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -233,10 +233,11 @@ func (r *Repository) ensureRunnerProjectionTables() error { show_in_command_panel INTEGER DEFAULT 1, auto_archive_after_hours INTEGER DEFAULT 0, agent_profile_id TEXT NOT NULL DEFAULT '', - profile_session_start_policy TEXT NOT NULL DEFAULT 'reuse', - profile_session_end_policy TEXT NOT NULL DEFAULT 'complete', - stage_type TEXT NOT NULL DEFAULT 'custom', - auto_advance_requires_signal INTEGER NOT NULL DEFAULT 0, + profile_session_start_policy TEXT NOT NULL DEFAULT 'reuse', + profile_session_end_policy TEXT NOT NULL DEFAULT 'park', + stage_type TEXT NOT NULL DEFAULT 'custom', + session_target TEXT, + auto_advance_requires_signal INTEGER NOT NULL DEFAULT 0, cancel_triggers_turn_complete INTEGER NOT NULL DEFAULT 0, complete_task_on_enter INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/apps/backend/internal/task/repository/sqlite/defaults.go b/apps/backend/internal/task/repository/sqlite/defaults.go index ad02a93f7e..b5a16608ab 100644 --- a/apps/backend/internal/task/repository/sqlite/defaults.go +++ b/apps/backend/internal/task/repository/sqlite/defaults.go @@ -467,19 +467,23 @@ func (r *Repository) insertTemplateStep( if stage == "" { stage = wfmodels.StageTypeCustom } + sessionTargetJSON, err := json.Marshal(wfmodels.RemapWorkflowSessionTarget(def.SessionTarget, idMap)) + if err != nil { + return fmt.Errorf("marshal session target for step %s: %w", def.Name, err) + } _, err = r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO workflow_steps ( id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, show_in_command_panel, - auto_archive_after_hours, agent_profile_id, profile_session_start_policy, profile_session_end_policy, stage_type, + auto_archive_after_hours, agent_profile_id, profile_session_start_policy, profile_session_end_policy, stage_type, session_target, auto_advance_requires_signal, cancel_triggers_turn_complete, complete_task_on_enter, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), idMap[def.ID], workflowID, def.Name, def.Position, def.Color, def.Prompt, string(eventsJSON), dialect.BoolToInt(def.AllowManualMove), dialect.BoolToInt(def.IsStartStep), dialect.BoolToInt(def.ShowInCommandPanel), - def.AutoArchiveAfterHours, def.AgentProfileID, models.NormalizeWorkflowProfileSessionStartPolicy(string(def.ProfileSessionStartPolicy)), models.NormalizeWorkflowProfileSessionEndPolicy(string(def.ProfileSessionEndPolicy)), string(stage), + def.AutoArchiveAfterHours, def.AgentProfileID, models.NormalizeWorkflowProfileSessionStartPolicy(string(def.ProfileSessionStartPolicy)), models.NormalizeWorkflowProfileSessionEndPolicy(string(def.ProfileSessionEndPolicy)), string(stage), string(sessionTargetJSON), dialect.BoolToInt(def.AutoAdvanceRequiresSignal), dialect.BoolToInt(def.CancelTriggersTurnComplete), dialect.BoolToInt(def.CompleteTaskOnEnter), now, now, ) diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity_test.go b/apps/backend/internal/task/repository/sqlite/repository_entity_test.go index 12bfbf102b..8b48bbe9ca 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity_test.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity_test.go @@ -723,6 +723,18 @@ func TestRunnerProjectionWorkflowStepColumnsReplayMigration(t *testing.T) { } } +func TestRunnerProjectionWorkflowSessionEndPolicyDefaultsToPark(t *testing.T) { + repo := newRepoForEntityTests(t) + + var endPolicyDefault string + if err := repo.db.QueryRow(`SELECT dflt_value FROM pragma_table_info('workflow_steps') WHERE name = 'profile_session_end_policy'`).Scan(&endPolicyDefault); err != nil { + t.Fatalf("inspect session end policy default: %v", err) + } + if endPolicyDefault != "'park'" { + t.Fatalf("profile_session_end_policy schema default = %q, want 'park'", endPolicyDefault) + } +} + func TestRunnerProjectionParticipantCreatedAtReplayMigration(t *testing.T) { repo := newRepoForEntityTests(t) diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 749862e1be..21604221b0 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -517,6 +517,26 @@ func (r *Repository) CreateTaskSession(ctx context.Context, session *models.Task // removal one serialized operation, so a concurrent launch or replacement // session cannot inherit the seed a second time. func (r *Repository) CreateTaskSessionWithInitialRuntimeSeed(ctx context.Context, session *models.TaskSession) error { + return r.createTaskSessionWithInitialRuntimeSeed(ctx, session, nil) +} + +// CreateTaskSessionWithInitialRuntimeSeedAndWorkflowRoute atomically claims +// the launch-only runtime seed, inserts the session, and records a prepared +// workflow route. The route destination is therefore durable before any +// external lifecycle work can promote or launch the session. +func (r *Repository) CreateTaskSessionWithInitialRuntimeSeedAndWorkflowRoute( + ctx context.Context, + session *models.TaskSession, + route *models.WorkflowSessionRoute, +) error { + return r.createTaskSessionWithInitialRuntimeSeed(ctx, session, route) +} + +func (r *Repository) createTaskSessionWithInitialRuntimeSeed( + ctx context.Context, + session *models.TaskSession, + route *models.WorkflowSessionRoute, +) error { tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return err @@ -533,6 +553,34 @@ func (r *Repository) CreateTaskSessionWithInitialRuntimeSeed(ctx context.Context if err := r.createTaskSession(ctx, tx, session); err != nil { return err } + if err := r.persistWorkflowSessionRouteTx(ctx, tx, session.TaskID, session.ID, route); err != nil { + return err + } + return tx.Commit() +} + +// CreateTaskSessionWithWorkflowSessionRoute inserts a session and its +// prepared route in one transaction for repositories that do not need a +// launch-only seed or workspace election. +func (r *Repository) CreateTaskSessionWithWorkflowSessionRoute( + ctx context.Context, + session *models.TaskSession, + route *models.WorkflowSessionRoute, +) error { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if err := r.taskCleanupBarrierLocked(ctx, tx, session.TaskID); err != nil { + return err + } + if err := r.createTaskSession(ctx, tx, session); err != nil { + return err + } + if err := r.persistWorkflowSessionRouteTx(ctx, tx, session.TaskID, session.ID, route); err != nil { + return err + } return tx.Commit() } @@ -551,6 +599,28 @@ func (r *Repository) CreateTaskSessionWithWorkspaceBinding( ctx context.Context, session *models.TaskSession, candidate *models.TaskEnvironment, +) error { + return r.createTaskSessionWithWorkspaceBinding(ctx, session, candidate, nil) +} + +// CreateTaskSessionWithWorkspaceBindingAndWorkflowRoute is the route-aware +// variant used by explicit workflow starts. It keeps workspace election, +// session insertion, and prepared-route recording in one transaction. +func (r *Repository) CreateTaskSessionWithWorkspaceBindingAndWorkflowRoute( + ctx context.Context, + session *models.TaskSession, + candidate *models.TaskEnvironment, + route *models.WorkflowSessionRoute, +) error { + return r.createTaskSessionWithWorkspaceBinding(ctx, session, candidate, route) +} + +//nolint:cyclop // The state cases are the durable workspace binding state machine. +func (r *Repository) createTaskSessionWithWorkspaceBinding( + ctx context.Context, + session *models.TaskSession, + candidate *models.TaskEnvironment, + route *models.WorkflowSessionRoute, ) error { if candidate == nil { return fmt.Errorf("workspace binding candidate is required") @@ -614,6 +684,9 @@ func (r *Repository) CreateTaskSessionWithWorkspaceBinding( if err := r.createTaskSession(ctx, tx, session); err != nil { return err } + if err := r.persistWorkflowSessionRouteTx(ctx, tx, session.TaskID, session.ID, route); err != nil { + return err + } return tx.Commit() } @@ -627,6 +700,28 @@ func (r *Repository) CreateTaskSessionWithSharedGroupWorkspaceBinding( session *models.TaskSession, candidate *models.TaskEnvironment, groupID string, +) error { + return r.createTaskSessionWithSharedGroupWorkspaceBinding(ctx, session, candidate, groupID, nil) +} + +// CreateTaskSessionWithSharedGroupWorkspaceBindingAndWorkflowRoute is the +// route-aware shared-workspace variant used by explicit workflow starts. +func (r *Repository) CreateTaskSessionWithSharedGroupWorkspaceBindingAndWorkflowRoute( + ctx context.Context, + session *models.TaskSession, + candidate *models.TaskEnvironment, + groupID string, + route *models.WorkflowSessionRoute, +) error { + return r.createTaskSessionWithSharedGroupWorkspaceBinding(ctx, session, candidate, groupID, route) +} + +func (r *Repository) createTaskSessionWithSharedGroupWorkspaceBinding( + ctx context.Context, + session *models.TaskSession, + candidate *models.TaskEnvironment, + groupID string, + route *models.WorkflowSessionRoute, ) error { if candidate == nil || groupID == "" { return fmt.Errorf("shared workspace binding requires a candidate and group") @@ -673,7 +768,7 @@ func (r *Repository) CreateTaskSessionWithSharedGroupWorkspaceBinding( return fmt.Errorf("inspect shared workspace election: %w", err) } if won == 0 { - return r.bindReadySharedGroupEnvironment(ctx, tx, session, groupID) + return r.bindReadySharedGroupEnvironment(ctx, tx, session, groupID, route) } if err := r.insertCreatingWorkspaceEnvironment(ctx, tx, candidate); err != nil { @@ -686,6 +781,9 @@ func (r *Repository) CreateTaskSessionWithSharedGroupWorkspaceBinding( if err := r.createTaskSession(ctx, tx, session); err != nil { return err } + if err := r.persistWorkflowSessionRouteTx(ctx, tx, session.TaskID, session.ID, route); err != nil { + return err + } return tx.Commit() } @@ -694,6 +792,7 @@ func (r *Repository) bindReadySharedGroupEnvironment( tx *sqlx.Tx, session *models.TaskSession, groupID string, + route *models.WorkflowSessionRoute, ) error { var environmentID, status string err := tx.QueryRowContext(ctx, r.db.Rebind(` @@ -724,6 +823,9 @@ func (r *Repository) bindReadySharedGroupEnvironment( if err := r.createTaskSession(ctx, tx, session); err != nil { return err } + if err := r.persistWorkflowSessionRouteTx(ctx, tx, session.TaskID, session.ID, route); err != nil { + return err + } return tx.Commit() } @@ -917,6 +1019,11 @@ func (r *Repository) createTaskSession(ctx context.Context, exec taskSessionExec if session.State == "" { session.State = models.TaskSessionStateCreated } + if tx, ok := exec.(*sqlx.Tx); ok { + if err := r.prepareWorkflowInitialSessionTx(ctx, tx, session); err != nil { + return err + } + } metadataJSON, err := json.Marshal(session.Metadata) if err != nil { @@ -974,6 +1081,167 @@ func (r *Repository) createTaskSession(ctx context.Context, exec taskSessionExec return err } +func (r *Repository) persistWorkflowSessionRouteTx( + ctx context.Context, + exec taskSessionExecutor, + taskID, destinationID string, + route *models.WorkflowSessionRoute, +) error { + if route == nil { + return nil + } + prepared := *route + if prepared.DestinationID == "" { + prepared.DestinationID = destinationID + } + if prepared.DestinationID != destinationID { + return fmt.Errorf("workflow route destination does not match session") + } + if prepared.Phase == "" { + prepared.Phase = "prepared" + } + return r.setTaskMetadataKeyWithExecutor(ctx, exec, taskID, models.MetaKeyWorkflowSessionRoute, prepared, time.Now().UTC()) +} + +func (r *Repository) setTaskMetadataKeyWithExecutor( + ctx context.Context, + exec taskSessionExecutor, + taskID, key string, + value interface{}, + updatedAt time.Time, +) error { + payload, err := json.Marshal(value) + if err != nil { + return err + } + if updatedAt.IsZero() { + updatedAt = time.Now().UTC() + } + var query string + if dialect.IsPostgres(r.db.DriverName()) { + query = `UPDATE tasks SET metadata = jsonb_set(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END, ARRAY[?]::text[], ?::jsonb, true)::text, updated_at = ? WHERE id = ?` + } else { + query = `UPDATE tasks SET metadata = json_set(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, ?, json(?)), updated_at = ? WHERE id = ?` + } + path := key + if !dialect.IsPostgres(r.db.DriverName()) { + path = jsonPath(key) + } + result, err := exec.ExecContext(ctx, r.db.Rebind(query), path, string(payload), updatedAt, taskID) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("task not found: %s", taskID) + } + return nil +} + +func (r *Repository) commitWorkflowSessionRouteTx( + ctx context.Context, + tx *sqlx.Tx, + taskID, sessionID string, + route *models.WorkflowSessionRoute, + now time.Time, +) error { + if route == nil { + return nil + } + var metadataJSON sql.NullString + if err := tx.QueryRowContext(ctx, r.db.Rebind(`SELECT metadata FROM tasks WHERE id = ?`), taskID).Scan(&metadataJSON); err != nil { + return err + } + metadata := make(map[string]interface{}) + if metadataJSON.Valid && metadataJSON.String != "" && metadataJSON.String != "null" { + if err := json.Unmarshal([]byte(metadataJSON.String), &metadata); err != nil { + return fmt.Errorf("decode workflow session route: %w", err) + } + } + recorded, ok := models.LoadWorkflowSessionRoute(metadata) + if !ok || recorded.OperationID != route.OperationID || recorded.DestinationID != sessionID { + return fmt.Errorf("workflow session route was superseded before promotion") + } + committed := *route + committed.DestinationID = sessionID + committed.Phase = "committed" + return r.setTaskMetadataKeyWithExecutor(ctx, tx, taskID, models.MetaKeyWorkflowSessionRoute, committed, now) +} + +// prepareWorkflowInitialSessionTx elects the first task session and records +// its immutable workflow-routing snapshot in the same transaction as the +// session insert. Later callers cannot replace the snapshot or leave a stale +// origin marker on a session created after the first one. +func (r *Repository) prepareWorkflowInitialSessionTx(ctx context.Context, tx *sqlx.Tx, session *models.TaskSession) error { + var sessionCount int + if err := tx.QueryRowContext(ctx, r.db.Rebind( + `SELECT COUNT(*) FROM task_sessions WHERE task_id = ?`, + ), session.TaskID).Scan(&sessionCount); err != nil { + return fmt.Errorf("check task sessions before workflow snapshot: %w", err) + } + if sessionCount == 0 { + if session.Metadata == nil { + session.Metadata = make(map[string]interface{}) + } + session.Metadata[models.SessionMetaKeyOrigin] = models.SessionOriginTaskInitial + _, err := r.setTaskMetadataKeyIfAbsentWithoutTimestampWithExecutor(ctx, tx, session.TaskID, models.MetaKeyWorkflowInitialSession, models.WorkflowInitialSessionSnapshot{ + SessionID: session.ID, + AgentProfileID: session.AgentProfileID, + }) + return err + } + if models.IsOriginalTaskSession(session.Metadata) { + delete(session.Metadata, models.SessionMetaKeyOrigin) + } + return nil +} + +func (r *Repository) setTaskMetadataKeyIfAbsentWithoutTimestampWithExecutor(ctx context.Context, exec taskSessionExecutor, taskID, key string, value interface{}) (bool, error) { + return r.setTaskMetadataKeyIfAbsentWithExecutorOptions(ctx, exec, taskID, key, value, false) +} + +func (r *Repository) setTaskMetadataKeyIfAbsentWithExecutorOptions(ctx context.Context, exec taskSessionExecutor, taskID, key string, value interface{}, updateTaskTimestamp bool) (bool, error) { + payload, err := json.Marshal(value) + if err != nil { + return false, err + } + args := []interface{}{} + var query string + if dialect.IsPostgres(r.db.DriverName()) { + query = `UPDATE tasks + SET metadata = jsonb_set(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END, ARRAY[?]::text[], ?::jsonb, true)::text` + if updateTaskTimestamp { + query += `, updated_at = ?` + } + query += ` WHERE id = ? AND jsonb_extract_path(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END, ?) IS NULL` + } else { + query = `UPDATE tasks + SET metadata = json_set(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, ?, json(?))` + if updateTaskTimestamp { + query += `, updated_at = ?` + } + query += ` WHERE id = ? AND json_type(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, ?) IS NULL` + } + path := key + if !dialect.IsPostgres(r.db.DriverName()) { + path = jsonPath(key) + } + args = append(args, path, string(payload)) + if updateTaskTimestamp { + args = append(args, time.Now().UTC()) + } + args = append(args, taskID, path) + result, err := exec.ExecContext(ctx, r.db.Rebind(query), args...) + if err != nil { + return false, err + } + rows, err := result.RowsAffected() + return rows > 0, err +} + // unmarshalSessionJSON deserializes a JSON string into dest, skipping empty/placeholder values. func unmarshalSessionJSON(jsonStr string, dest interface{}, fieldDesc string) error { if jsonStr == "" || jsonStr == "{}" { @@ -2981,6 +3249,15 @@ func (r *Repository) DeleteTaskSessionWithAttachments( if err != nil { return nil, err } + if _, err := tx.ExecContext(ctx, r.db.Rebind(` + UPDATE task_workflow_session_bindings + SET session_id = NULL, updated_at = ? + WHERE session_id = ? + `), r.nowUTC(), session.ID); err != nil { + if !db.IsMissingTableError(err) { + return nil, fmt.Errorf("clear workflow session binding: %w", err) + } + } if err := tx.Commit(); err != nil { return nil, err } @@ -3431,7 +3708,7 @@ func (r *Repository) GetPrimarySessionInfoByTaskIDs(ctx context.Context, taskIDs // (`SELECT ... FOR UPDATE`) before touching its sessions, so a second // concurrent promotion for the same task blocks until the first commits. func (r *Repository) SetSessionPrimary(ctx context.Context, sessionID string) error { - _, err := r.setSessionPrimary(ctx, sessionID, false) + _, err := r.setSessionPrimary(ctx, sessionID, false, nil) return err } @@ -3439,10 +3716,28 @@ func (r *Repository) SetSessionPrimary(ctx context.Context, sessionID string) er // nonterminal. It is used by workflow profile switching so a completed agent // cannot be promoted from a stale lookup and have its ACP conversation resumed. func (r *Repository) SetSessionPrimaryIfNonterminal(ctx context.Context, sessionID string) (bool, error) { - return r.setSessionPrimary(ctx, sessionID, true) + return r.setSessionPrimary(ctx, sessionID, true, nil) } -func (r *Repository) setSessionPrimary(ctx context.Context, sessionID string, requireNonterminal bool) (bool, error) { +// SetSessionPrimaryWithWorkflowSessionRouteIfNonterminal promotes a route +// destination and commits its prepared route in the same database transaction. +// The operation and destination checks fence a delayed workflow entry from +// promoting or committing over a newer route. +func (r *Repository) SetSessionPrimaryWithWorkflowSessionRouteIfNonterminal( + ctx context.Context, + sessionID string, + route models.WorkflowSessionRoute, +) (bool, error) { + return r.setSessionPrimary(ctx, sessionID, true, &route) +} + +//nolint:cyclop // Primary promotion preserves one transaction boundary for both legacy and route-aware callers. +func (r *Repository) setSessionPrimary( + ctx context.Context, + sessionID string, + requireNonterminal bool, + route *models.WorkflowSessionRoute, +) (bool, error) { now := time.Now().UTC() tx, err := r.db.BeginTxx(ctx, nil) @@ -3488,6 +3783,12 @@ func (r *Repository) setSessionPrimary(ctx context.Context, sessionID string, re } } + if route != nil { + if err := r.commitWorkflowSessionRouteTx(ctx, tx, taskID, sessionID, route, now); err != nil { + return false, err + } + } + // Clear primary flag on all sessions for this task _, err = tx.ExecContext(ctx, r.db.Rebind(` UPDATE task_sessions SET is_primary = 0, updated_at = ? WHERE task_id = ? diff --git a/apps/backend/internal/task/repository/sqlite/session_test.go b/apps/backend/internal/task/repository/sqlite/session_test.go index 1dbacd9376..02005668cc 100644 --- a/apps/backend/internal/task/repository/sqlite/session_test.go +++ b/apps/backend/internal/task/repository/sqlite/session_test.go @@ -170,6 +170,49 @@ func TestCreateOfficeTaskSessionMarksOnlyTheFirstConcurrentSessionAsOrigin(t *te } } +func TestCreateTaskSessionPersistsImmutableWorkflowInitialSnapshot(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const taskID = "task-workflow-initial-snapshot" + require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: taskID, Title: "Initial snapshot"})) + + first := &models.TaskSession{ID: "initial-snapshot-session", TaskID: taskID, AgentProfileID: "profile-original"} + require.NoError(t, repo.CreateTaskSession(ctx, first)) + + task, err := repo.GetTask(ctx, taskID) + require.NoError(t, err) + snapshot, ok := models.LoadWorkflowInitialSessionSnapshot(task.Metadata) + require.True(t, ok) + require.Equal(t, models.WorkflowInitialSessionSnapshot{ + SessionID: first.ID, + AgentProfileID: first.AgentProfileID, + }, snapshot) + + second := &models.TaskSession{ + ID: "later-snapshot-session", + TaskID: taskID, + AgentProfileID: "profile-later", + Metadata: map[string]interface{}{ + models.SessionMetaKeyOrigin: models.SessionOriginTaskInitial, + }, + } + require.NoError(t, repo.CreateTaskSession(ctx, second)) + + task, err = repo.GetTask(ctx, taskID) + require.NoError(t, err) + snapshot, ok = models.LoadWorkflowInitialSessionSnapshot(task.Metadata) + require.True(t, ok) + require.Equal(t, first.ID, snapshot.SessionID) + require.Equal(t, first.AgentProfileID, snapshot.AgentProfileID) + + require.NoError(t, repo.DeleteTaskSession(ctx, first)) + task, err = repo.GetTask(ctx, taskID) + require.NoError(t, err) + snapshot, ok = models.LoadWorkflowInitialSessionSnapshot(task.Metadata) + require.True(t, ok) + require.Equal(t, first.ID, snapshot.SessionID) +} + func TestCreateTaskSessionWithInitialRuntimeSeedConsumesOnceAcrossConcurrentAndReplacementSessions(t *testing.T) { repo := newRepoForSessionTests(t) ctx := context.Background() diff --git a/apps/backend/internal/task/repository/sqlite/step_transitions.go b/apps/backend/internal/task/repository/sqlite/step_transitions.go index fb155f9491..3dc15213a2 100644 --- a/apps/backend/internal/task/repository/sqlite/step_transitions.go +++ b/apps/backend/internal/task/repository/sqlite/step_transitions.go @@ -139,6 +139,28 @@ func (r *Repository) recordStepTransition(ctx context.Context, tx stepTransition return id, nil } +// GetLatestTaskStepTransitionID returns the immutable ledger identity of the +// task's latest workflow-step entry. Loaded task projections intentionally do +// not carry the transient transition field, so retryable workflow routing uses +// this read when it needs to reconstruct the current entry after a restart. +func (r *Repository) GetLatestTaskStepTransitionID(ctx context.Context, taskID string) (int64, error) { + var id int64 + err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` + SELECT id + FROM task_step_transitions + WHERE task_id = ? + ORDER BY id DESC + LIMIT 1 + `), taskID).Scan(&id) + if err == sql.ErrNoRows { + return 0, nil + } + if err != nil { + return 0, err + } + return id, nil +} + // formatEntryID converts recordStepTransition's ledger identifier into the // step-entry requirement's entry identity string (AC-OFFICE-STEP-ENTRY- // 001.2, .7). id 0 means recordStepTransition was a no-op — dispatchStepEntry diff --git a/apps/backend/internal/task/repository/sqlite/step_transitions_test.go b/apps/backend/internal/task/repository/sqlite/step_transitions_test.go index 871399f611..319b495e7f 100644 --- a/apps/backend/internal/task/repository/sqlite/step_transitions_test.go +++ b/apps/backend/internal/task/repository/sqlite/step_transitions_test.go @@ -165,6 +165,45 @@ func TestGenesisRowRecordsFeederStepWhenWIPDivertsPlacement(t *testing.T) { } } +func TestGetLatestTaskStepTransitionID(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + createStepTransitionsTestTask(t, repo, "task-latest-transition", "wf-1", "step-a") + + genesisID, err := repo.GetLatestTaskStepTransitionID(ctx, "task-latest-transition") + if err != nil { + t.Fatalf("GetLatestTaskStepTransitionID after create: %v", err) + } + if genesisID == 0 { + t.Fatal("GetLatestTaskStepTransitionID after create = 0, want genesis row") + } + + task, err := repo.GetTask(ctx, "task-latest-transition") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + task.WorkflowStepID = "step-b" + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("UpdateTask: %v", err) + } + + latestID, err := repo.GetLatestTaskStepTransitionID(ctx, "task-latest-transition") + if err != nil { + t.Fatalf("GetLatestTaskStepTransitionID after move: %v", err) + } + if latestID <= genesisID { + t.Fatalf("latest transition id = %d, want greater than genesis %d", latestID, genesisID) + } + + missingID, err := repo.GetLatestTaskStepTransitionID(ctx, "missing-task") + if err != nil { + t.Fatalf("GetLatestTaskStepTransitionID missing task: %v", err) + } + if missingID != 0 { + t.Fatalf("missing task transition id = %d, want 0", missingID) + } +} + func TestUpdateTaskMoveWritesOneRowWithAttribution(t *testing.T) { repo := newStepTransitionsTestRepo(t) ctx := steptelemetry.WithAttribution(context.Background(), steptelemetry.Attribution{ diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index b1e3570126..91d07c8d6c 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -1521,6 +1521,36 @@ func (r *Repository) SetTaskMetadataKeyIfPresent(ctx context.Context, taskID, ke return rows > 0, err } +// SetTaskMetadataKeyIfAbsent writes one task metadata key only while the key +// is absent. The predicate and write share one statement so concurrent first +// session creation cannot replace the immutable workflow snapshot. +func (r *Repository) SetTaskMetadataKeyIfAbsent(ctx context.Context, taskID, key string, value interface{}) (bool, error) { + payload, err := json.Marshal(value) + if err != nil { + return false, err + } + var query string + if dialect.IsPostgres(r.db.DriverName()) { + query = `UPDATE tasks + SET metadata = jsonb_set(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END, ARRAY[?]::text[], ?::jsonb, true)::text, updated_at = ? + WHERE id = ? AND jsonb_extract_path(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END, ?) IS NULL` + } else { + query = `UPDATE tasks + SET metadata = json_set(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, ?, json(?)), updated_at = ? + WHERE id = ? AND json_type(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, ?) IS NULL` + } + path := key + if !dialect.IsPostgres(r.db.DriverName()) { + path = jsonPath(key) + } + result, err := r.db.ExecContext(ctx, r.db.Rebind(query), path, string(payload), time.Now().UTC(), taskID, path) + if err != nil { + return false, err + } + rows, err := result.RowsAffected() + return rows > 0, err +} + func jsonPath(key string) string { return "$." + key } func agentTitlePendingPredicate(driver string) string { diff --git a/apps/backend/internal/task/repository/sqlite/workflow.go b/apps/backend/internal/task/repository/sqlite/workflow.go index 5b99ace061..7ac8da2f1a 100644 --- a/apps/backend/internal/task/repository/sqlite/workflow.go +++ b/apps/backend/internal/task/repository/sqlite/workflow.go @@ -270,30 +270,54 @@ func (r *Repository) UpdateWorkflow(ctx context.Context, workflow *models.Workfl // DeleteWorkflowsByWorkspace deletes all workflows for a workspace except the excluded IDs (E2E cleanup). // Relies on CASCADE foreign keys to remove workflow_steps. func (r *Repository) DeleteWorkflowsByWorkspace(ctx context.Context, workspaceID string, excludeIDs []string) (int64, error) { - if len(excludeIDs) == 0 { - result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM workflows WHERE workspace_id = ?`), workspaceID) - if err != nil { - return 0, err - } - rows, _ := result.RowsAffected() - return rows, nil - } - - query, args, err := sqlx.In(`DELETE FROM workflows WHERE workspace_id = ? AND id NOT IN (?)`, workspaceID, excludeIDs) + tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return 0, err } - result, err := r.db.ExecContext(ctx, r.db.Rebind(query), args...) + defer func() { _ = tx.Rollback() }() + + var query string + var args []interface{} + if len(excludeIDs) == 0 { + query = `DELETE FROM workflows WHERE workspace_id = ?` + args = []interface{}{workspaceID} + } else { + query, args, err = sqlx.In(`DELETE FROM workflows WHERE workspace_id = ? AND id NOT IN (?)`, workspaceID, excludeIDs) + if err != nil { + return 0, err + } + } + cleanupQuery := ` + DELETE FROM task_workflow_session_bindings + WHERE workflow_id IN (SELECT id FROM workflows WHERE ` + query[len("DELETE FROM workflows WHERE "):] + `) + ` + if _, err := tx.ExecContext(ctx, r.db.Rebind(cleanupQuery), args...); err != nil { + return 0, err + } + result, err := tx.ExecContext(ctx, r.db.Rebind(query), args...) if err != nil { return 0, err } rows, _ := result.RowsAffected() + if err := tx.Commit(); err != nil { + return 0, err + } return rows, nil } // DeleteWorkflow deletes a workflow by ID func (r *Repository) DeleteWorkflow(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM workflows WHERE id = ?`), id) + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, r.db.Rebind(` + DELETE FROM task_workflow_session_bindings WHERE workflow_id = ? + `), id); err != nil { + return err + } + result, err := tx.ExecContext(ctx, r.db.Rebind(`DELETE FROM workflows WHERE id = ?`), id) if err != nil { return err } @@ -302,7 +326,7 @@ func (r *Repository) DeleteWorkflow(ctx context.Context, id string) error { if rows == 0 { return fmt.Errorf("workflow not found: %s", id) } - return nil + return tx.Commit() } // ListWorkflows returns workflows for the given workspace, excluding hidden by default. diff --git a/apps/backend/internal/task/repository/sqlite/workflow_session_bindings.go b/apps/backend/internal/task/repository/sqlite/workflow_session_bindings.go new file mode 100644 index 0000000000..2507c9aff2 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/workflow_session_bindings.go @@ -0,0 +1,129 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +const workflowSessionBindingsTableDDL = ` + CREATE TABLE IF NOT EXISTS task_workflow_session_bindings ( + task_id TEXT NOT NULL, + target_key TEXT NOT NULL, + workflow_id TEXT NOT NULL, + agent_profile_id TEXT NOT NULL, + session_id TEXT, + operation_id TEXT NOT NULL, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY (task_id, target_key), + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_task_workflow_session_bindings_workflow + ON task_workflow_session_bindings(workflow_id, target_key); + CREATE INDEX IF NOT EXISTS idx_task_workflow_session_bindings_session + ON task_workflow_session_bindings(session_id); +` + +func (r *Repository) migrateWorkflowSessionBindings() error { + return r.migrate.Apply("task_workflow_session_bindings.table", workflowSessionBindingsTableDDL) +} + +// GetWorkflowSessionBinding loads the latest committed binding for a source +// step. A missing row is a valid skipped-source state. +func (r *Repository) GetWorkflowSessionBinding( + ctx context.Context, + taskID, targetKey string, +) (*models.WorkflowSessionBinding, error) { + var binding models.WorkflowSessionBinding + var sessionID sql.NullString + err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(` + SELECT task_id, target_key, workflow_id, agent_profile_id, + session_id, operation_id, updated_at + FROM task_workflow_session_bindings + WHERE task_id = ? AND target_key = ? + `), taskID, targetKey).Scan( + &binding.TaskID, + &binding.TargetKey, + &binding.WorkflowID, + &binding.AgentProfileID, + &sessionID, + &binding.OperationID, + &binding.UpdatedAt, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("load workflow session binding: %w", err) + } + if sessionID.Valid { + binding.SessionID = sessionID.String + } + return &binding, nil +} + +// UpsertWorkflowSessionBinding commits a source-step session choice. Versioned +// step-entry operations compare their immutable entry identity inside the same +// upsert statement, while legacy operations retain timestamp ordering. The +// bool reports whether this operation won the conditional write. +func (r *Repository) UpsertWorkflowSessionBinding( + ctx context.Context, + binding *models.WorkflowSessionBinding, +) (bool, error) { + if binding == nil || binding.TaskID == "" || binding.TargetKey == "" || + binding.WorkflowID == "" || binding.AgentProfileID == "" || binding.OperationID == "" { + return false, fmt.Errorf("workflow session binding identity is required") + } + if binding.UpdatedAt.IsZero() { + binding.UpdatedAt = time.Now().UTC() + } + + query := ` + INSERT INTO task_workflow_session_bindings + (task_id, target_key, workflow_id, agent_profile_id, session_id, operation_id, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (task_id, target_key) DO UPDATE SET + workflow_id = EXCLUDED.workflow_id, + agent_profile_id = EXCLUDED.agent_profile_id, + session_id = EXCLUDED.session_id, + operation_id = EXCLUDED.operation_id, + updated_at = EXCLUDED.updated_at + WHERE ( + (task_workflow_session_bindings.operation_id LIKE 'workflow-step-entry-v2:%' + AND ( + task_workflow_session_bindings.operation_id < EXCLUDED.operation_id + OR ( + task_workflow_session_bindings.operation_id = EXCLUDED.operation_id + AND task_workflow_session_bindings.updated_at < EXCLUDED.updated_at + ) + )) + OR (task_workflow_session_bindings.operation_id NOT LIKE 'workflow-step-entry-v2:%' + AND task_workflow_session_bindings.updated_at < EXCLUDED.updated_at) + ) + ` + result, err := r.db.ExecContext(ctx, r.db.Rebind(query), + binding.TaskID, + binding.TargetKey, + binding.WorkflowID, + binding.AgentProfileID, + nullableBindingSessionID(binding.SessionID), + binding.OperationID, + binding.UpdatedAt, + ) + if err != nil { + return false, fmt.Errorf("upsert workflow session binding: %w", err) + } + rows, err := result.RowsAffected() + return rows > 0, err +} + +func nullableBindingSessionID(sessionID string) interface{} { + if sessionID == "" { + return nil + } + return sessionID +} diff --git a/apps/backend/internal/task/repository/sqlite/workflow_session_bindings_test.go b/apps/backend/internal/task/repository/sqlite/workflow_session_bindings_test.go new file mode 100644 index 0000000000..bbdc06c749 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/workflow_session_bindings_test.go @@ -0,0 +1,163 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + "github.com/stretchr/testify/require" +) + +func TestWorkflowSessionBindingRejectsStaleOperationAndRetainsProfileAfterSessionDelete(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const ( + taskID = "task-workflow-binding" + workflowID = "workflow-workflow-binding" + targetKey = "step:implement" + ) + require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: taskID, Title: "Binding"})) + session := &models.TaskSession{ + ID: "workflow-binding-session", + TaskID: taskID, + AgentProfileID: "profile-implement", + State: models.TaskSessionStateWaitingForInput, + } + require.NoError(t, repo.CreateTaskSession(ctx, session)) + + newer := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC) + accepted, err := repo.UpsertWorkflowSessionBinding(ctx, &models.WorkflowSessionBinding{ + TaskID: taskID, + TargetKey: targetKey, + WorkflowID: workflowID, + AgentProfileID: session.AgentProfileID, + SessionID: session.ID, + OperationID: "operation-new", + UpdatedAt: newer, + }) + require.NoError(t, err) + require.True(t, accepted) + + accepted, err = repo.UpsertWorkflowSessionBinding(ctx, &models.WorkflowSessionBinding{ + TaskID: taskID, + TargetKey: targetKey, + WorkflowID: workflowID, + AgentProfileID: session.AgentProfileID, + SessionID: "stale-session", + OperationID: "operation-stale", + UpdatedAt: newer.Add(-time.Second), + }) + require.NoError(t, err) + require.False(t, accepted) + + binding, err := repo.GetWorkflowSessionBinding(ctx, taskID, targetKey) + require.NoError(t, err) + require.Equal(t, session.ID, binding.SessionID) + require.Equal(t, "operation-new", binding.OperationID) + + require.NoError(t, repo.DeleteTaskSession(ctx, session)) + binding, err = repo.GetWorkflowSessionBinding(ctx, taskID, targetKey) + require.NoError(t, err) + require.NotNil(t, binding) + require.Empty(t, binding.SessionID) + require.Equal(t, session.AgentProfileID, binding.AgentProfileID) +} + +func TestWorkflowSessionBindingRejectsEqualTimestampReplacement(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const ( + taskID = "task-workflow-binding-equal" + workflowID = "workflow-workflow-binding-equal" + targetKey = "step:review" + ) + require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: taskID, Title: "Equal timestamp"})) + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-first", TaskID: taskID, AgentProfileID: "profile-review", + State: models.TaskSessionStateWaitingForInput, + })) + stamp := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC) + + accepted, err := repo.UpsertWorkflowSessionBinding(ctx, &models.WorkflowSessionBinding{ + TaskID: taskID, + TargetKey: targetKey, + WorkflowID: workflowID, + AgentProfileID: "profile-review", + SessionID: "session-first", + OperationID: "operation-first", + UpdatedAt: stamp, + }) + require.NoError(t, err) + require.True(t, accepted) + + accepted, err = repo.UpsertWorkflowSessionBinding(ctx, &models.WorkflowSessionBinding{ + TaskID: taskID, + TargetKey: targetKey, + WorkflowID: workflowID, + AgentProfileID: "profile-review", + SessionID: "session-equal", + OperationID: "operation-equal", + UpdatedAt: stamp, + }) + require.NoError(t, err) + require.False(t, accepted) + + binding, err := repo.GetWorkflowSessionBinding(ctx, taskID, targetKey) + require.NoError(t, err) + require.Equal(t, "session-first", binding.SessionID) + require.Equal(t, "operation-first", binding.OperationID) +} + +func TestWorkflowSessionBindingAllowsNewerSessionWithinSameEntry(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const taskID = "task-workflow-binding-same-entry" + const targetKey = "step:implement" + require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: taskID, Title: "Same entry"})) + first := &models.TaskSession{ID: "session-same-entry-first", TaskID: taskID, AgentProfileID: "profile-a", State: models.TaskSessionStateRunning} + second := &models.TaskSession{ID: "session-same-entry-second", TaskID: taskID, AgentProfileID: "profile-a", State: models.TaskSessionStateRunning} + require.NoError(t, repo.CreateTaskSession(ctx, first)) + require.NoError(t, repo.CreateTaskSession(ctx, second)) + + entryOperation := "workflow-step-entry-v2:entry:00000000000000000021" + firstStamp := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC) + secondStamp := firstStamp.Add(time.Second) + accepted, err := repo.UpsertWorkflowSessionBinding(ctx, &models.WorkflowSessionBinding{ + TaskID: taskID, TargetKey: targetKey, WorkflowID: "workflow-same-entry", AgentProfileID: "profile-a", + SessionID: first.ID, OperationID: entryOperation, UpdatedAt: firstStamp, + }) + require.NoError(t, err) + require.True(t, accepted) + accepted, err = repo.UpsertWorkflowSessionBinding(ctx, &models.WorkflowSessionBinding{ + TaskID: taskID, TargetKey: targetKey, WorkflowID: "workflow-same-entry", AgentProfileID: "profile-a", + SessionID: second.ID, OperationID: entryOperation, UpdatedAt: secondStamp, + }) + require.NoError(t, err) + require.True(t, accepted) + + binding, err := repo.GetWorkflowSessionBinding(ctx, taskID, targetKey) + require.NoError(t, err) + require.Equal(t, second.ID, binding.SessionID) +} + +func TestWorkflowSessionBindingsCascadeWithTask(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const taskID = "task-workflow-binding-cascade" + require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: taskID, Title: "Binding cascade"})) + accepted, err := repo.UpsertWorkflowSessionBinding(ctx, &models.WorkflowSessionBinding{ + TaskID: taskID, + TargetKey: "step:implement", + WorkflowID: "workflow-cascade", + AgentProfileID: "profile-implement", + OperationID: "operation-cascade", + }) + require.NoError(t, err) + require.True(t, accepted) + + require.NoError(t, repo.DeleteTask(ctx, taskID)) + binding, err := repo.GetWorkflowSessionBinding(ctx, taskID, "step:implement") + require.NoError(t, err) + require.Nil(t, binding) +} diff --git a/apps/backend/internal/task/repository/sqlite/workspace_bootstrap.go b/apps/backend/internal/task/repository/sqlite/workspace_bootstrap.go index cd2eace8c0..256621d203 100644 --- a/apps/backend/internal/task/repository/sqlite/workspace_bootstrap.go +++ b/apps/backend/internal/task/repository/sqlite/workspace_bootstrap.go @@ -78,17 +78,21 @@ func (r *Repository) insertTemplateSteps(ctx context.Context, tx *sqlx.Tx, workf if err != nil { return fmt.Errorf("marshal Kanban step %q: %w", stepDef.Name, err) } + sessionTarget, err := json.Marshal(wfmodels.RemapWorkflowSessionTarget(stepDef.SessionTarget, idMap)) + if err != nil { + return fmt.Errorf("marshal Kanban session target %q: %w", stepDef.Name, err) + } if _, err := tx.ExecContext(ctx, r.db.Rebind(` INSERT INTO workflow_steps ( id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, show_in_command_panel, - auto_archive_after_hours, agent_profile_id, profile_session_start_policy, profile_session_end_policy, stage_type, + auto_archive_after_hours, agent_profile_id, profile_session_start_policy, profile_session_end_policy, stage_type, session_target, auto_advance_requires_signal, cancel_triggers_turn_complete, wip_limit, pull_from_step_id, complete_task_on_enter, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), idMap[stepDef.ID], workflowID, stepDef.Name, stepDef.Position, stepDef.Color, stepDef.Prompt, string(events), dialect.BoolToInt(stepDef.AllowManualMove), dialect.BoolToInt(stepDef.IsStartStep), dialect.BoolToInt(stepDef.ShowInCommandPanel), - stepDef.AutoArchiveAfterHours, stepDef.AgentProfileID, models.NormalizeWorkflowProfileSessionStartPolicy(string(stepDef.ProfileSessionStartPolicy)), models.NormalizeWorkflowProfileSessionEndPolicy(string(stepDef.ProfileSessionEndPolicy)), normalizeBootstrapStageType(stepDef.StageType), + stepDef.AutoArchiveAfterHours, stepDef.AgentProfileID, models.NormalizeWorkflowProfileSessionStartPolicy(string(stepDef.ProfileSessionStartPolicy)), models.NormalizeWorkflowProfileSessionEndPolicy(string(stepDef.ProfileSessionEndPolicy)), normalizeBootstrapStageType(stepDef.StageType), string(sessionTarget), dialect.BoolToInt(stepDef.AutoAdvanceRequiresSignal), dialect.BoolToInt(stepDef.CancelTriggersTurnComplete), stepDef.WIPLimit, wfmodels.RemapStepID(stepDef.PullFromStepID, idMap), dialect.BoolToInt(stepDef.CompleteTaskOnEnter), now, now); err != nil { return fmt.Errorf("create Kanban step %q: %w", stepDef.Name, err) } diff --git a/apps/backend/internal/workflow/controller/controller.go b/apps/backend/internal/workflow/controller/controller.go index 7f1c4da625..c6c0eedbc5 100644 --- a/apps/backend/internal/workflow/controller/controller.go +++ b/apps/backend/internal/workflow/controller/controller.go @@ -141,6 +141,7 @@ type CreateStepRequest struct { CompleteTaskOnEnter *bool `json:"complete_task_on_enter,omitempty"` ProfileSessionStartPolicy *string `json:"profile_session_start_policy,omitempty"` ProfileSessionEndPolicy *string `json:"profile_session_end_policy,omitempty"` + SessionTarget SessionTargetPatch `json:"session_target,omitempty"` WIPLimit *int `json:"wip_limit,omitempty"` PullFromStepID *string `json:"pull_from_step_id,omitempty"` } @@ -213,6 +214,9 @@ func (c *Controller) CreateStep(ctx context.Context, req CreateStepRequest) (*Ge if req.ProfileSessionEndPolicy != nil { step.ProfileSessionEndPolicy = taskmodels.NormalizeWorkflowProfileSessionEndPolicy(*req.ProfileSessionEndPolicy) } + if req.SessionTarget.Set { + step.SessionTarget = models.CloneWorkflowSessionTarget(req.SessionTarget.Target) + } if req.AutoAdvanceRequiresSignal != nil { step.AutoAdvanceRequiresSignal = *req.AutoAdvanceRequiresSignal } @@ -260,6 +264,7 @@ type UpdateStepRequest struct { CompleteTaskOnEnter *bool `json:"complete_task_on_enter,omitempty"` ProfileSessionStartPolicy *string `json:"profile_session_start_policy,omitempty"` ProfileSessionEndPolicy *string `json:"profile_session_end_policy,omitempty"` + SessionTarget SessionTargetPatch `json:"session_target,omitempty"` WIPLimit *int `json:"wip_limit,omitempty"` PullFromStepID *string `json:"pull_from_step_id,omitempty"` } @@ -329,6 +334,9 @@ func (c *Controller) UpdateStep(ctx context.Context, req UpdateStepRequest) (*Ge if req.ProfileSessionEndPolicy != nil { step.ProfileSessionEndPolicy = taskmodels.NormalizeWorkflowProfileSessionEndPolicy(*req.ProfileSessionEndPolicy) } + if req.SessionTarget.Set { + step.SessionTarget = models.CloneWorkflowSessionTarget(req.SessionTarget.Target) + } if req.AutoAdvanceRequiresSignal != nil { step.AutoAdvanceRequiresSignal = *req.AutoAdvanceRequiresSignal } @@ -367,6 +375,12 @@ func (c *Controller) UpdateStep(ctx context.Context, req UpdateStepRequest) (*Ge // the reason, because there is nothing to hide from them and the editor has to // be able to explain it. func (c *Controller) validateStepReferences(ctx context.Context, step *models.WorkflowStep) error { + if err := c.validateSessionTarget(ctx, step); err != nil { + return err + } + if err := c.validateIncomingSessionTargets(ctx, step.WorkflowID, step, nil); err != nil { + return err + } if err := c.validatePullFromStep(ctx, step); err != nil { return err } @@ -384,6 +398,110 @@ func (c *Controller) validateStepReferences(ctx context.Context, step *models.Wo return nil } +func (c *Controller) validateSessionTarget(ctx context.Context, step *models.WorkflowStep) error { + if err := models.ValidateWorkflowSessionTarget(step.SessionTarget); err != nil { + return fmt.Errorf("session_target is invalid: %w", err) + } + if step.SessionTarget == nil { + return nil + } + if step.AgentProfileID != "" { + return fmt.Errorf("session_target cannot be combined with agent_profile_id") + } + if step.SessionTarget.Kind == models.WorkflowSessionTargetInitial { + return nil + } + if step.ID != "" && step.SessionTarget.StepID == step.ID { + return fmt.Errorf("session_target cannot reference the same step") + } + if err := c.svc.AuthorizeStep(ctx, step.SessionTarget.StepID); err != nil { + return fmt.Errorf("session_target is invalid: %w", err) + } + target, err := c.svc.GetStep(ctx, step.SessionTarget.StepID) + if err != nil { + return fmt.Errorf("session_target is invalid: %w", err) + } + if target.WorkflowID != step.WorkflowID { + return fmt.Errorf("session_target must reference a step in the same workflow") + } + if target.Position >= step.Position { + return fmt.Errorf("session_target must reference an earlier step") + } + if target.AgentProfileID == "" { + return fmt.Errorf("session_target must reference a step with an agent profile") + } + if target.SessionTarget != nil { + return fmt.Errorf("session_target cannot reference a step with another session target") + } + return nil +} + +// validateIncomingSessionTargets validates references pointing at the step +// being written, not only the target carried by that step. This prevents a +// profile removal or position edit from silently invalidating dependent +// destinations. +func (c *Controller) validateIncomingSessionTargets( + ctx context.Context, + workflowID string, + replacement *models.WorkflowStep, + positions map[string]int, +) error { + steps, err := c.svc.ListStepsByWorkflow(ctx, workflowID) + if err != nil { + return err + } + byID := make(map[string]*models.WorkflowStep, len(steps)+1) + for _, existing := range steps { + if existing != nil { + byID[existing.ID] = existing + } + } + if replacement != nil { + byID[replacement.ID] = replacement + } + for _, current := range byID { + if current == nil || current.SessionTarget == nil || current.SessionTarget.Kind != models.WorkflowSessionTargetStep { + continue + } + if err := validateIncomingSessionTarget(workflowID, current, byID, positions); err != nil { + return err + } + } + return nil +} + +func validateIncomingSessionTarget( + workflowID string, + current *models.WorkflowStep, + byID map[string]*models.WorkflowStep, + positions map[string]int, +) error { + source, ok := byID[current.SessionTarget.StepID] + if !ok || source == nil { + return fmt.Errorf("session_target source step %q was not found", current.SessionTarget.StepID) + } + currentPosition := current.Position + sourcePosition := source.Position + if positions != nil { + if value, found := positions[current.ID]; found { + currentPosition = value + } + if value, found := positions[source.ID]; found { + sourcePosition = value + } + } + if source.WorkflowID != workflowID || source.WorkflowID != current.WorkflowID { + return fmt.Errorf("session_target must reference a step in the same workflow") + } + if sourcePosition >= currentPosition { + return fmt.Errorf("session_target source step %q must remain earlier than %q", source.ID, current.ID) + } + if source.AgentProfileID == "" || source.SessionTarget != nil { + return fmt.Errorf("session_target source step %q must keep a direct agent profile", source.ID) + } + return nil +} + // validateEventStepTarget keeps a transition inside its own workflow. The // engine dereferences the target when the trigger fires and drives the task // with whatever it finds there — that step's prompt and agent profile — so a @@ -485,6 +603,17 @@ func (c *Controller) DeleteStep(ctx context.Context, id string) error { if err := c.svc.EnsureWorkflowMutable(ctx, step.WorkflowID); err != nil { return err } + steps, err := c.svc.ListStepsByWorkflow(ctx, step.WorkflowID) + if err != nil { + return err + } + for _, dependent := range steps { + if dependent != nil && dependent.SessionTarget != nil && + dependent.SessionTarget.Kind == models.WorkflowSessionTargetStep && + dependent.SessionTarget.StepID == id { + return fmt.Errorf("workflow step %q is the session target of %q", id, dependent.ID) + } + } return c.svc.DeleteStep(ctx, id) } @@ -504,6 +633,13 @@ func (c *Controller) ReorderSteps(ctx context.Context, req ReorderStepsRequest) if err := c.svc.EnsureWorkflowMutable(ctx, req.WorkflowID); err != nil { return err } + positions := make(map[string]int, len(req.StepIDs)) + for position, stepID := range req.StepIDs { + positions[stepID] = position + } + if err := c.validateIncomingSessionTargets(ctx, req.WorkflowID, nil, positions); err != nil { + return err + } return c.svc.ReorderSteps(ctx, req.WorkflowID, req.StepIDs) } diff --git a/apps/backend/internal/workflow/controller/session_target.go b/apps/backend/internal/workflow/controller/session_target.go new file mode 100644 index 0000000000..671a647866 --- /dev/null +++ b/apps/backend/internal/workflow/controller/session_target.go @@ -0,0 +1,33 @@ +package controller + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/kandev/kandev/internal/workflow/models" +) + +// SessionTargetPatch distinguishes an omitted request field from an explicit +// null, which clears a previously configured target. +type SessionTargetPatch struct { + Set bool + Target *models.WorkflowSessionTarget +} + +func (p *SessionTargetPatch) UnmarshalJSON(data []byte) error { + p.Set = true + if bytes.Equal(bytes.TrimSpace(data), []byte("null")) { + p.Target = nil + return nil + } + var target models.WorkflowSessionTarget + if err := json.Unmarshal(data, &target); err != nil { + return fmt.Errorf("decode session_target: %w", err) + } + if err := models.ValidateWorkflowSessionTarget(&target); err != nil { + return err + } + p.Target = &target + return nil +} diff --git a/apps/backend/internal/workflow/controller/session_target_test.go b/apps/backend/internal/workflow/controller/session_target_test.go new file mode 100644 index 0000000000..4353943f73 --- /dev/null +++ b/apps/backend/internal/workflow/controller/session_target_test.go @@ -0,0 +1,36 @@ +package controller + +import ( + "encoding/json" + "testing" + + "github.com/kandev/kandev/internal/workflow/models" + "github.com/stretchr/testify/require" +) + +func TestSessionTargetPatchPreservesOmissionAndExplicitNull(t *testing.T) { + var omitted struct { + Target SessionTargetPatch `json:"session_target,omitempty"` + } + require.NoError(t, json.Unmarshal([]byte(`{}`), &omitted)) + require.False(t, omitted.Target.Set) + + var cleared struct { + Target SessionTargetPatch `json:"session_target,omitempty"` + } + require.NoError(t, json.Unmarshal([]byte(`{"session_target":null}`), &cleared)) + require.True(t, cleared.Target.Set) + require.Nil(t, cleared.Target.Target) +} + +func TestSessionTargetPatchValidatesClosedUnion(t *testing.T) { + var request struct { + Target SessionTargetPatch `json:"session_target"` + } + require.NoError(t, json.Unmarshal([]byte(`{"session_target":{"kind":"initial"}}`), &request)) + require.True(t, request.Target.Set) + require.Equal(t, &models.WorkflowSessionTarget{Kind: models.WorkflowSessionTargetInitial}, request.Target.Target) + + err := json.Unmarshal([]byte(`{"session_target":{"kind":"unknown"}}`), &request) + require.ErrorContains(t, err, "unknown session target kind") +} diff --git a/apps/backend/internal/workflow/handlers/http_handlers_test.go b/apps/backend/internal/workflow/handlers/http_handlers_test.go index 3368a2bdec..9b8e32c89e 100644 --- a/apps/backend/internal/workflow/handlers/http_handlers_test.go +++ b/apps/backend/internal/workflow/handlers/http_handlers_test.go @@ -498,7 +498,7 @@ func TestExportWorkflowEndpoints(t *testing.T) { t.Run("single workflow export carries its steps", func(t *testing.T) { h := newExportHarness(t) export := decodeExport(t, doJSON(t, h.router, http.MethodGet, "/api/v1/workflows/workflow-1/export", nil)) - if export.Version != models.ExportVersion || export.Type != models.ExportType { + if export.Version != models.LegacyExportVersion || export.Type != models.ExportType { t.Fatalf("export envelope = %d/%q", export.Version, export.Type) } if len(export.Workflows) != 1 || export.Workflows[0].Name != "Main" { diff --git a/apps/backend/internal/workflow/models/export.go b/apps/backend/internal/workflow/models/export.go index 5b4f698938..b4fd742f5c 100644 --- a/apps/backend/internal/workflow/models/export.go +++ b/apps/backend/internal/workflow/models/export.go @@ -42,6 +42,13 @@ type AgentProfileResolver func(profileID string) *AgentProfilePortable // existing bindings (docs/specs/agents/requirements/profile-disable.md). type AgentProfileMatcher func(agentName, model, mode, currentID string) string +// WorkflowSessionTargetPortable stores a target with a position instead of a +// workflow-local step ID so an export can be imported into another workflow. +type WorkflowSessionTargetPortable struct { + Kind WorkflowSessionTargetKind `json:"kind" yaml:"kind"` + StepPosition *int `json:"step_position,omitempty" yaml:"step_position,omitempty"` +} + // WorkflowPortable is a workflow without instance-specific fields (IDs, timestamps). type WorkflowPortable struct { Name string `json:"name" yaml:"name"` @@ -72,6 +79,7 @@ type StepPortable struct { PullFromStepPosition *int `json:"pull_from_step_position,omitempty" yaml:"pull_from_step_position,omitempty"` completionTaskOnEnterDecoded bool `json:"-" yaml:"-"` completionTaskOnEnterPresent bool `json:"-" yaml:"-"` + SessionTarget *WorkflowSessionTargetPortable `json:"session_target,omitempty" yaml:"session_target,omitempty"` } // UnmarshalJSON records whether a decoded document explicitly supplied the @@ -144,20 +152,43 @@ func (s *StepPortable) markCompletionTaskOnEnterExplicit() { // BuildWorkflowExport builds a portable WorkflowExport from domain models. // stepsByWorkflow maps workflow ID → its steps (ordered by position). // resolveProfile converts agent profile IDs to portable form (may be nil). +// Invalid target references return nil for compatibility with older callers; +// service callers should use BuildWorkflowExportWithError to surface the +// configuration error to the user. func BuildWorkflowExport(workflows []*taskmodels.Workflow, stepsByWorkflow map[string][]*WorkflowStep, resolveProfile AgentProfileResolver) *WorkflowExport { - portable := make([]WorkflowPortable, 0, len(workflows)) - for _, wf := range workflows { - steps := stepsByWorkflow[wf.ID] - portable = append(portable, buildWorkflowPortable(wf, steps, resolveProfile)) - } - return &WorkflowExport{ - Version: ExportVersion, - Type: ExportType, - Workflows: portable, + export, err := BuildWorkflowExportWithError(workflows, stepsByWorkflow, resolveProfile) + if err != nil { + return nil } + return export } -func buildWorkflowPortable(wf *taskmodels.Workflow, steps []*WorkflowStep, resolveProfile AgentProfileResolver) WorkflowPortable { +// BuildWorkflowExportWithError builds a portable export and rejects unresolved +// step targets before they can produce an import-invalid document. +func BuildWorkflowExportWithError(workflows []*taskmodels.Workflow, stepsByWorkflow map[string][]*WorkflowStep, resolveProfile AgentProfileResolver) (*WorkflowExport, error) { + portable := make([]WorkflowPortable, 0, len(workflows)) + version := LegacyExportVersion + for _, wf := range workflows { + steps := stepsByWorkflow[wf.ID] + for _, step := range steps { + if step != nil && (step.SessionTarget != nil || step.CompleteTaskOnEnter) { + version = ExportVersion + } + } + workflow, err := buildWorkflowPortable(wf, steps, resolveProfile) + if err != nil { + return nil, err + } + portable = append(portable, workflow) + } + return &WorkflowExport{ + Version: version, + Type: ExportType, + Workflows: portable, + }, nil +} + +func buildWorkflowPortable(wf *taskmodels.Workflow, steps []*WorkflowStep, resolveProfile AgentProfileResolver) (WorkflowPortable, error) { portableSteps := make([]StepPortable, 0, len(steps)) // Build step ID → position map for converting move_to_step references. idToPos := make(map[string]int, len(steps)) @@ -165,6 +196,10 @@ func buildWorkflowPortable(wf *taskmodels.Workflow, steps []*WorkflowStep, resol idToPos[s.ID] = s.Position } for _, s := range steps { + sessionTarget, err := buildPortableSessionTarget(s.SessionTarget, idToPos) + if err != nil { + return WorkflowPortable{}, fmt.Errorf("workflow %q step %q: %w", wf.Name, s.Name, err) + } sp := StepPortable{ Name: s.Name, Position: s.Position, @@ -177,6 +212,7 @@ func buildWorkflowPortable(wf *taskmodels.Workflow, steps []*WorkflowStep, resol AutoArchiveAfterHours: s.AutoArchiveAfterHours, ProfileSessionStartPolicy: taskmodels.NormalizeWorkflowProfileSessionStartPolicy(string(s.ProfileSessionStartPolicy)), ProfileSessionEndPolicy: taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(s.ProfileSessionEndPolicy)), + SessionTarget: sessionTarget, AutoAdvanceRequiresSignal: s.AutoAdvanceRequiresSignal, CancelTriggersTurnComplete: s.CancelTriggersTurnComplete, CompleteTaskOnEnter: s.CompleteTaskOnEnter, @@ -200,7 +236,25 @@ func buildWorkflowPortable(wf *taskmodels.Workflow, steps []*WorkflowStep, resol if resolveProfile != nil && wf.AgentProfileID != "" { wp.AgentProfile = resolveProfile(wf.AgentProfileID) } - return wp + return wp, nil +} + +func buildPortableSessionTarget(target *WorkflowSessionTarget, idToPos map[string]int) (*WorkflowSessionTargetPortable, error) { + if target == nil { + return nil, nil + } + if err := ValidateWorkflowSessionTarget(target); err != nil { + return nil, err + } + portable := &WorkflowSessionTargetPortable{Kind: target.Kind} + if target.Kind == WorkflowSessionTargetStep { + if position, ok := idToPos[target.StepID]; ok { + portable.StepPosition = &position + } else { + return nil, fmt.Errorf("session target source step %q was not found", target.StepID) + } + } + return portable, nil } // Validate checks that the export data is well-formed. @@ -240,6 +294,11 @@ func (e *WorkflowExport) Validate() error { return fmt.Errorf("workflow %d step %d: wip_limit must be non-negative", i, j) } } + for j, step := range wf.Steps { + if err := validatePortableSessionTarget(e.Version, step, positions); err != nil { + return fmt.Errorf("workflow %d step %d: %w", i, j, err) + } + } // Validate that move_to_step references point to valid positions. if err := validateStepPositionRefs(wf.Steps, positions); err != nil { return fmt.Errorf("workflow %d: %w", i, err) @@ -247,6 +306,61 @@ func (e *WorkflowExport) Validate() error { if err := validatePullSourceRefs(wf.Steps, positions); err != nil { return fmt.Errorf("workflow %d: %w", i, err) } + if err := validatePortableSessionTargets(wf.Steps); err != nil { + return fmt.Errorf("workflow %d: %w", i, err) + } + } + return nil +} + +func validatePortableSessionTarget(version int, step StepPortable, validPositions map[int]bool) error { + if step.SessionTarget == nil { + return nil + } + if version == LegacyExportVersion { + return fmt.Errorf("session_target requires export version %d", ExportVersion) + } + switch step.SessionTarget.Kind { + case WorkflowSessionTargetInitial: + if step.SessionTarget.StepPosition != nil { + return fmt.Errorf("initial session target must not include step_position") + } + case WorkflowSessionTargetStep: + if step.SessionTarget.StepPosition == nil { + return fmt.Errorf("step session target requires step_position") + } + position := *step.SessionTarget.StepPosition + if !validPositions[position] { + return fmt.Errorf("session target step_position %d does not match any step", position) + } + if position >= step.Position { + return fmt.Errorf("session target step_position %d must reference an earlier step", position) + } + if step.AgentProfile != nil { + return fmt.Errorf("session_target cannot be combined with an agent_profile") + } + default: + return fmt.Errorf("unknown session target kind %q", step.SessionTarget.Kind) + } + return nil +} + +func validatePortableSessionTargets(steps []StepPortable) error { + byPosition := make(map[int]StepPortable, len(steps)) + for _, step := range steps { + byPosition[step.Position] = step + } + for _, step := range steps { + if step.SessionTarget == nil || step.SessionTarget.Kind != WorkflowSessionTargetStep { + continue + } + if step.SessionTarget.StepPosition == nil { + return fmt.Errorf("step %q session target requires step_position", step.Name) + } + target := byPosition[*step.SessionTarget.StepPosition] + if target.AgentProfile == nil || target.SessionTarget != nil { + return fmt.Errorf("step %q session target must reference a direct-profile step", step.Name) + } } return nil } @@ -349,12 +463,24 @@ func (s StepPortable) PullFromStepID(posToID map[int]string) string { // an import would "succeed" with an inert action. See issue #1183. This mirrors // the embedded-YAML loader's allow-list check. func validateOnEnterActions(step StepPortable) error { - if err := ValidateStepEvents(step.Events, step.AgentProfile != nil); err != nil { + if err := ValidateStepEventsWithRouting(step.Events, step.AgentProfile != nil, step.SessionTarget != nil); err != nil { return fmt.Errorf("step %q on_enter: %w", step.Name, err) } return nil } +// WorkflowSessionTarget maps the portable position to a workflow-local step ID. +func (s StepPortable) WorkflowSessionTarget(posToID map[int]string) *WorkflowSessionTarget { + if s.SessionTarget == nil { + return nil + } + target := &WorkflowSessionTarget{Kind: s.SessionTarget.Kind} + if s.SessionTarget.StepPosition != nil { + target.StepID = posToID[*s.SessionTarget.StepPosition] + } + return target +} + func validateStepPositionRefs(steps []StepPortable, validPositions map[int]bool) error { for _, step := range steps { for _, a := range step.Events.OnTurnStart { diff --git a/apps/backend/internal/workflow/models/export_test.go b/apps/backend/internal/workflow/models/export_test.go index 27a888d68b..ae9062c28a 100644 --- a/apps/backend/internal/workflow/models/export_test.go +++ b/apps/backend/internal/workflow/models/export_test.go @@ -34,7 +34,7 @@ func TestBuildWorkflowExport(t *testing.T) { export := BuildWorkflowExport([]*taskmodels.Workflow{wf}, stepMap, nil) - require.Equal(t, ExportVersion, export.Version) + require.Equal(t, LegacyExportVersion, export.Version) require.Equal(t, ExportType, export.Type) require.Len(t, export.Workflows, 1) @@ -100,6 +100,24 @@ func TestBuildWorkflowExport(t *testing.T) { require.NotNil(t, export.Workflows[0].Steps[1].PullFromStepPosition) assert.Equal(t, 0, *export.Workflows[0].Steps[1].PullFromStepPosition) }) + + t.Run("exports explicit target as version two portable position", func(t *testing.T) { + wf := &taskmodels.Workflow{ID: "wf-1", Name: "Targeted Workflow"} + steps := []*WorkflowStep{ + {ID: "source", Name: "Source", Position: 0, AgentProfileID: "profile-a"}, + {ID: "target", Name: "Target", Position: 1, SessionTarget: &WorkflowSessionTarget{Kind: WorkflowSessionTargetStep, StepID: "source"}}, + } + + export := BuildWorkflowExport([]*taskmodels.Workflow{wf}, map[string][]*WorkflowStep{"wf-1": steps}, func(profileID string) *AgentProfilePortable { + return &AgentProfilePortable{AgentName: profileID} + }) + require.Equal(t, ExportVersion, export.Version) + require.NotNil(t, export.Workflows[0].Steps[1].SessionTarget) + assert.Equal(t, WorkflowSessionTargetStep, export.Workflows[0].Steps[1].SessionTarget.Kind) + require.NotNil(t, export.Workflows[0].Steps[1].SessionTarget.StepPosition) + assert.Equal(t, 0, *export.Workflows[0].Steps[1].SessionTarget.StepPosition) + require.NoError(t, export.Validate()) + }) } func TestValidate(t *testing.T) { @@ -123,6 +141,19 @@ func TestValidate(t *testing.T) { assert.NoError(t, validExport().Validate()) }) + t.Run("version two export passes", func(t *testing.T) { + e := validExport() + e.Version = 2 + assert.NoError(t, e.Validate()) + }) + + t.Run("legacy version rejects explicit targets", func(t *testing.T) { + e := validExport() + e.Version = LegacyExportVersion + e.Workflows[0].Steps[1].SessionTarget = &WorkflowSessionTargetPortable{Kind: WorkflowSessionTargetInitial} + require.ErrorContains(t, e.Validate(), "requires export version") + }) + t.Run("wrong version fails", func(t *testing.T) { e := validExport() e.Version = 99 diff --git a/apps/backend/internal/workflow/models/models.go b/apps/backend/internal/workflow/models/models.go index b2ee553858..5803168aba 100644 --- a/apps/backend/internal/workflow/models/models.go +++ b/apps/backend/internal/workflow/models/models.go @@ -199,6 +199,7 @@ type StepDefinition struct { AgentProfileID string `json:"agent_profile_id,omitempty" yaml:"agent_profile_id,omitempty"` ProfileSessionStartPolicy taskmodels.WorkflowProfileSessionStartPolicy `json:"profile_session_start_policy,omitempty" yaml:"profile_session_start_policy,omitempty"` ProfileSessionEndPolicy taskmodels.WorkflowProfileSessionEndPolicy `json:"profile_session_end_policy,omitempty" yaml:"profile_session_end_policy,omitempty"` + SessionTarget *WorkflowSessionTarget `json:"session_target,omitempty" yaml:"session_target,omitempty"` WIPLimit int `json:"wip_limit,omitempty" yaml:"wip_limit,omitempty"` PullFromStepID string `json:"pull_from_step_id,omitempty" yaml:"pull_from_step_id,omitempty"` // StageType mirrors WorkflowStep.StageType for templates so the office @@ -231,6 +232,7 @@ type WorkflowStep struct { AgentProfileID string `json:"agent_profile_id,omitempty"` ProfileSessionStartPolicy taskmodels.WorkflowProfileSessionStartPolicy `json:"profile_session_start_policy,omitempty"` ProfileSessionEndPolicy taskmodels.WorkflowProfileSessionEndPolicy `json:"profile_session_end_policy,omitempty"` + SessionTarget *WorkflowSessionTarget `json:"session_target,omitempty"` WIPLimit int `json:"wip_limit,omitempty"` PullFromStepID string `json:"pull_from_step_id,omitempty"` // StageType is a Phase 2 (ADR-0004) semantic hint for the frontend diff --git a/apps/backend/internal/workflow/models/session_config.go b/apps/backend/internal/workflow/models/session_config.go index fe8d5d79bb..ec488e301b 100644 --- a/apps/backend/internal/workflow/models/session_config.go +++ b/apps/backend/internal/workflow/models/session_config.go @@ -85,12 +85,21 @@ func ValidateWorkflowStep(step *WorkflowStep) error { if step == nil { return fmt.Errorf("workflow step is required") } - return ValidateStepEvents(step.Events, step.AgentProfileID != "") + if err := ValidateWorkflowSessionTarget(step.SessionTarget); err != nil { + return err + } + return ValidateStepEventsWithRouting(step.Events, step.AgentProfileID != "", step.SessionTarget != nil) } // ValidateStepEvents validates on-enter action invariants for a step shape // that does not carry a full WorkflowStep, such as a portable export. func ValidateStepEvents(events StepEvents, hasAgentProfile bool) error { + return ValidateStepEventsWithRouting(events, hasAgentProfile, false) +} + +// ValidateStepEventsWithRouting validates on-enter action invariants for a +// step shape that may select an explicit session target. +func ValidateStepEventsWithRouting(events StepEvents, hasAgentProfile, hasSessionTarget bool) error { configureCount := 0 for _, action := range events.OnEnter { switch action.Type { @@ -99,6 +108,9 @@ func ValidateStepEvents(events StepEvents, hasAgentProfile bool) error { if hasAgentProfile { return fmt.Errorf("configure_session cannot be combined with agent_profile_id") } + if hasSessionTarget { + return fmt.Errorf("configure_session cannot be combined with a session target") + } if _, err := ParseConfigureSessionRules(action); err != nil { return err } diff --git a/apps/backend/internal/workflow/models/session_target.go b/apps/backend/internal/workflow/models/session_target.go new file mode 100644 index 0000000000..1d8491a820 --- /dev/null +++ b/apps/backend/internal/workflow/models/session_target.go @@ -0,0 +1,70 @@ +package models + +import "fmt" + +// WorkflowSessionTargetKind identifies the conversation a workflow step may +// reuse when it starts a session. +type WorkflowSessionTargetKind string + +const ( + WorkflowSessionTargetInitial WorkflowSessionTargetKind = "initial" + WorkflowSessionTargetStep WorkflowSessionTargetKind = "step" +) + +// WorkflowSessionTarget is the durable workflow configuration for session +// reuse. StepID is required only for a step target and refers to an earlier +// step in the same workflow. +type WorkflowSessionTarget struct { + Kind WorkflowSessionTargetKind `json:"kind" yaml:"kind"` + StepID string `json:"step_id,omitempty" yaml:"step_id,omitempty"` +} + +// ValidateWorkflowSessionTarget validates the closed target union without +// needing the surrounding workflow. Position and ownership rules are checked +// by workflow validators. +func ValidateWorkflowSessionTarget(target *WorkflowSessionTarget) error { + if target == nil { + return nil + } + switch target.Kind { + case WorkflowSessionTargetInitial: + if target.StepID != "" { + return fmt.Errorf("initial session target must not include step_id") + } + case WorkflowSessionTargetStep: + if target.StepID == "" { + return fmt.Errorf("step session target requires step_id") + } + default: + return fmt.Errorf("unknown session target kind %q", target.Kind) + } + return nil +} + +// CloneWorkflowSessionTarget returns an independent target value. +func CloneWorkflowSessionTarget(target *WorkflowSessionTarget) *WorkflowSessionTarget { + if target == nil { + return nil + } + clone := *target + return &clone +} + +// RemapWorkflowSessionTarget replaces a template step alias with its newly +// generated workflow-step ID. +func RemapWorkflowSessionTarget(target *WorkflowSessionTarget, idMap map[string]string) *WorkflowSessionTarget { + clone := CloneWorkflowSessionTarget(target) + if clone == nil || clone.Kind != WorkflowSessionTargetStep { + return clone + } + clone.StepID = RemapStepID(clone.StepID, idMap) + return clone +} + +// EqualWorkflowSessionTarget compares nullable target values. +func EqualWorkflowSessionTarget(left, right *WorkflowSessionTarget) bool { + if left == nil || right == nil { + return left == right + } + return left.Kind == right.Kind && left.StepID == right.StepID +} diff --git a/apps/backend/internal/workflow/repository/profile_session_policy_test.go b/apps/backend/internal/workflow/repository/profile_session_policy_test.go index af3f0f176a..9c7020eeda 100644 --- a/apps/backend/internal/workflow/repository/profile_session_policy_test.go +++ b/apps/backend/internal/workflow/repository/profile_session_policy_test.go @@ -9,9 +9,17 @@ import ( ) func TestWorkflowStepProfileSessionPoliciesRoundTripAndDefaults(t *testing.T) { - repo := setupTestRepo(t) + repo, db := setupTestRepoWithDB(t) ctx := context.Background() + var endPolicyDefault string + if err := db.QueryRow(`SELECT dflt_value FROM pragma_table_info('workflow_steps') WHERE name = 'profile_session_end_policy'`).Scan(&endPolicyDefault); err != nil { + t.Fatalf("inspect session end policy default: %v", err) + } + if endPolicyDefault != "'park'" { + t.Fatalf("profile_session_end_policy schema default = %q, want 'park'", endPolicyDefault) + } + for index, policies := range []struct { start taskmodels.WorkflowProfileSessionStartPolicy end taskmodels.WorkflowProfileSessionEndPolicy @@ -50,15 +58,15 @@ func TestWorkflowStepProfileSessionPoliciesRoundTripAndDefaults(t *testing.T) { if err := repo.CreateStep(ctx, unknown); err != nil { t.Fatalf("create unknown: %v", err) } - if unknown.ProfileSessionStartPolicy != taskmodels.WorkflowProfileSessionStartPolicyReuse || unknown.ProfileSessionEndPolicy != taskmodels.WorkflowProfileSessionEndPolicyComplete { - t.Fatalf("created policies = %q/%q, want normalized reuse/complete", unknown.ProfileSessionStartPolicy, unknown.ProfileSessionEndPolicy) + if unknown.ProfileSessionStartPolicy != taskmodels.WorkflowProfileSessionStartPolicyReuse || unknown.ProfileSessionEndPolicy != taskmodels.WorkflowProfileSessionEndPolicyPark { + t.Fatalf("created policies = %q/%q, want normalized reuse/park", unknown.ProfileSessionStartPolicy, unknown.ProfileSessionEndPolicy) } got, err := repo.GetStep(ctx, unknown.ID) if err != nil { t.Fatalf("get unknown: %v", err) } - if got.ProfileSessionStartPolicy != taskmodels.WorkflowProfileSessionStartPolicyReuse || got.ProfileSessionEndPolicy != taskmodels.WorkflowProfileSessionEndPolicyComplete { - t.Fatalf("unknown policies = %q/%q, want reuse/complete", got.ProfileSessionStartPolicy, got.ProfileSessionEndPolicy) + if got.ProfileSessionStartPolicy != taskmodels.WorkflowProfileSessionStartPolicyReuse || got.ProfileSessionEndPolicy != taskmodels.WorkflowProfileSessionEndPolicyPark { + t.Fatalf("unknown policies = %q/%q, want reuse/park", got.ProfileSessionStartPolicy, got.ProfileSessionEndPolicy) } got.ProfileSessionStartPolicy = taskmodels.WorkflowProfileSessionStartPolicy(" unsupported update ") @@ -66,8 +74,8 @@ func TestWorkflowStepProfileSessionPoliciesRoundTripAndDefaults(t *testing.T) { if err := repo.UpdateStep(ctx, got); err != nil { t.Fatalf("normalize updated policies: %v", err) } - if got.ProfileSessionStartPolicy != taskmodels.WorkflowProfileSessionStartPolicyReuse || got.ProfileSessionEndPolicy != taskmodels.WorkflowProfileSessionEndPolicyComplete { - t.Fatalf("updated caller policies = %q/%q, want reuse/complete", got.ProfileSessionStartPolicy, got.ProfileSessionEndPolicy) + if got.ProfileSessionStartPolicy != taskmodels.WorkflowProfileSessionStartPolicyReuse || got.ProfileSessionEndPolicy != taskmodels.WorkflowProfileSessionEndPolicyPark { + t.Fatalf("updated caller policies = %q/%q, want reuse/park", got.ProfileSessionStartPolicy, got.ProfileSessionEndPolicy) } got.ProfileSessionStartPolicy = taskmodels.WorkflowProfileSessionStartPolicyNew diff --git a/apps/backend/internal/workflow/repository/sqlite.go b/apps/backend/internal/workflow/repository/sqlite.go index b593987d6f..2e228decf6 100644 --- a/apps/backend/internal/workflow/repository/sqlite.go +++ b/apps/backend/internal/workflow/repository/sqlite.go @@ -73,9 +73,10 @@ func (r *Repository) initSchema() error { show_in_command_panel INTEGER DEFAULT 1, auto_archive_after_hours INTEGER DEFAULT 0, profile_session_start_policy TEXT NOT NULL DEFAULT 'reuse', - profile_session_end_policy TEXT NOT NULL DEFAULT 'complete', + profile_session_end_policy TEXT NOT NULL DEFAULT 'park', wip_limit INTEGER NOT NULL DEFAULT 0, pull_from_step_id TEXT NOT NULL DEFAULT '', + session_target TEXT, auto_advance_requires_signal INTEGER NOT NULL DEFAULT 0, cancel_triggers_turn_complete INTEGER NOT NULL DEFAULT 0, complete_task_on_enter INTEGER NOT NULL DEFAULT 0, @@ -117,7 +118,7 @@ func (r *Repository) initSchema() error { r.migrate.Apply("workflow_steps.show_in_command_panel", `ALTER TABLE workflow_steps ADD COLUMN show_in_command_panel INTEGER DEFAULT 1`) r.migrate.Apply("workflow_steps.agent_profile_id", `ALTER TABLE workflow_steps ADD COLUMN agent_profile_id TEXT DEFAULT ''`) r.migrate.Apply("workflow_steps.profile_session_start_policy", `ALTER TABLE workflow_steps ADD COLUMN profile_session_start_policy TEXT NOT NULL DEFAULT 'reuse'`) - r.migrate.Apply("workflow_steps.profile_session_end_policy", `ALTER TABLE workflow_steps ADD COLUMN profile_session_end_policy TEXT NOT NULL DEFAULT 'complete'`) + _ = r.migrate.Apply("workflow_steps.profile_session_end_policy", `ALTER TABLE workflow_steps ADD COLUMN profile_session_end_policy TEXT NOT NULL DEFAULT 'park'`) // Phase 2 (ADR-0004) - workflow_steps.stage_type, a UX hint for the // frontend ("work" | "review" | "approval" | "custom"). Backend code // MUST NOT branch on it. Idempotent ALTER; default keeps existing rows at "custom". @@ -133,6 +134,7 @@ func (r *Repository) initSchema() error { _ = r.migrate.Apply("workflow_steps.complete_task_on_enter", `ALTER TABLE workflow_steps ADD COLUMN complete_task_on_enter INTEGER NOT NULL DEFAULT 0`) r.migrate.Apply("workflow_steps.wip_limit", `ALTER TABLE workflow_steps ADD COLUMN wip_limit INTEGER NOT NULL DEFAULT 0`) r.migrate.Apply("workflow_steps.pull_from_step_id", `ALTER TABLE workflow_steps ADD COLUMN pull_from_step_id TEXT NOT NULL DEFAULT ''`) + _ = r.migrate.Apply("workflow_steps.session_target", `ALTER TABLE workflow_steps ADD COLUMN session_target TEXT`) r.migrate.Apply("idx_workflow_steps_pull_from", ` CREATE INDEX IF NOT EXISTS idx_workflow_steps_pull_from ON workflow_steps(pull_from_step_id) @@ -246,22 +248,26 @@ func (r *Repository) seedDefaultWorkflowSteps() error { if err != nil { return fmt.Errorf("failed to marshal events: %w", err) } + sessionTargetJSON, err := marshalSessionTarget(models.RemapWorkflowSessionTarget(stepDef.SessionTarget, idMap)) + if err != nil { + return fmt.Errorf("failed to marshal session target: %w", err) + } if _, err := r.db.Exec(r.db.Rebind(` INSERT INTO workflow_steps ( id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, show_in_command_panel, agent_profile_id, profile_session_start_policy, profile_session_end_policy, - wip_limit, pull_from_step_id, auto_advance_requires_signal, + wip_limit, pull_from_step_id, session_target, auto_advance_requires_signal, cancel_triggers_turn_complete, complete_task_on_enter, created_at, updated_at ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) `), idMap[stepDef.ID], workflowID, stepDef.Name, stepDef.Position, stepDef.Color, stepDef.Prompt, string(eventsJSON), dialect.BoolToInt(stepDef.AllowManualMove), - dialect.BoolToInt(stepDef.IsStartStep), dialect.BoolToInt(stepDef.ShowInCommandPanel), stepDef.AgentProfileID, taskmodels.NormalizeWorkflowProfileSessionStartPolicy(string(stepDef.ProfileSessionStartPolicy)), taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(stepDef.ProfileSessionEndPolicy)), stepDef.WIPLimit, models.RemapStepID(stepDef.PullFromStepID, idMap), dialect.BoolToInt(stepDef.AutoAdvanceRequiresSignal), dialect.BoolToInt(stepDef.CancelTriggersTurnComplete), dialect.BoolToInt(stepDef.CompleteTaskOnEnter), now, now, + dialect.BoolToInt(stepDef.IsStartStep), dialect.BoolToInt(stepDef.ShowInCommandPanel), stepDef.AgentProfileID, taskmodels.NormalizeWorkflowProfileSessionStartPolicy(string(stepDef.ProfileSessionStartPolicy)), taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(stepDef.ProfileSessionEndPolicy)), stepDef.WIPLimit, models.RemapStepID(stepDef.PullFromStepID, idMap), sessionTargetJSON, dialect.BoolToInt(stepDef.AutoAdvanceRequiresSignal), dialect.BoolToInt(stepDef.CancelTriggersTurnComplete), dialect.BoolToInt(stepDef.CompleteTaskOnEnter), now, now, ); err != nil { return err } @@ -625,6 +631,10 @@ func (r *Repository) CreateStepWithDemotedStartSteps(ctx context.Context, step * step.UpdatedAt = now step.ProfileSessionStartPolicy = taskmodels.NormalizeWorkflowProfileSessionStartPolicy(string(step.ProfileSessionStartPolicy)) step.ProfileSessionEndPolicy = taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(step.ProfileSessionEndPolicy)) + sessionTargetJSON, err := marshalSessionTarget(step.SessionTarget) + if err != nil { + return nil, fmt.Errorf("failed to marshal session target: %w", err) + } eventsJSON, err := json.Marshal(step.Events) if err != nil { @@ -646,13 +656,13 @@ func (r *Repository) CreateStepWithDemotedStartSteps(ctx context.Context, step * } _, err = tx.ExecContext(ctx, tx.Rebind(` - INSERT INTO workflow_steps ( - id, workflow_id, name, position, color, - prompt, events, allow_manual_move, is_start_step, show_in_command_panel, auto_archive_after_hours, agent_profile_id, profile_session_start_policy, profile_session_end_policy, stage_type, auto_advance_requires_signal, cancel_triggers_turn_complete, complete_task_on_enter, wip_limit, pull_from_step_id, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO workflow_steps ( + id, workflow_id, name, position, color, + prompt, events, allow_manual_move, is_start_step, show_in_command_panel, auto_archive_after_hours, agent_profile_id, profile_session_start_policy, profile_session_end_policy, stage_type, auto_advance_requires_signal, cancel_triggers_turn_complete, complete_task_on_enter, wip_limit, pull_from_step_id, session_target, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), step.ID, step.WorkflowID, step.Name, step.Position, step.Color, step.Prompt, string(eventsJSON), dialect.BoolToInt(step.AllowManualMove), - dialect.BoolToInt(step.IsStartStep), dialect.BoolToInt(step.ShowInCommandPanel), step.AutoArchiveAfterHours, step.AgentProfileID, taskmodels.NormalizeWorkflowProfileSessionStartPolicy(string(step.ProfileSessionStartPolicy)), taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(step.ProfileSessionEndPolicy)), normalizeStageType(step.StageType), dialect.BoolToInt(step.AutoAdvanceRequiresSignal), dialect.BoolToInt(step.CancelTriggersTurnComplete), dialect.BoolToInt(step.CompleteTaskOnEnter), step.WIPLimit, step.PullFromStepID, step.CreatedAt, step.UpdatedAt) + dialect.BoolToInt(step.IsStartStep), dialect.BoolToInt(step.ShowInCommandPanel), step.AutoArchiveAfterHours, step.AgentProfileID, taskmodels.NormalizeWorkflowProfileSessionStartPolicy(string(step.ProfileSessionStartPolicy)), taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(step.ProfileSessionEndPolicy)), normalizeStageType(step.StageType), dialect.BoolToInt(step.AutoAdvanceRequiresSignal), dialect.BoolToInt(step.CancelTriggersTurnComplete), dialect.BoolToInt(step.CompleteTaskOnEnter), step.WIPLimit, step.PullFromStepID, sessionTargetJSON, step.CreatedAt, step.UpdatedAt) if err != nil { return nil, err } @@ -674,6 +684,17 @@ func normalizeStageType(s models.StageType) string { return string(models.StageTypeCustom) } +func marshalSessionTarget(target *models.WorkflowSessionTarget) (interface{}, error) { + if target == nil { + return nil, nil + } + data, err := json.Marshal(target) + if err != nil { + return nil, fmt.Errorf("marshal workflow session target: %w", err) + } + return string(data), nil +} + // scanStep scans a single workflow step row including JSON events parsing. func (r *Repository) scanStep(row interface { Scan(dest ...interface{}) error @@ -681,10 +702,10 @@ func (r *Repository) scanStep(row interface { step := &models.WorkflowStep{} var allowManualMove, isStartStep, showInCommandPanel, autoAdvanceRequiresSignal, cancelTriggersTurnComplete, completeTaskOnEnter int var autoArchiveAfterHours sql.NullInt64 - var color, prompt, eventsJSON, agentProfileID, profileSessionStartPolicy, profileSessionEndPolicy, stageType, pullFromStepID sql.NullString + var color, prompt, eventsJSON, agentProfileID, profileSessionStartPolicy, profileSessionEndPolicy, stageType, pullFromStepID, sessionTargetJSON sql.NullString err := row.Scan(&step.ID, &step.WorkflowID, &step.Name, &step.Position, &color, - &prompt, &eventsJSON, &allowManualMove, &isStartStep, &showInCommandPanel, &autoArchiveAfterHours, &agentProfileID, &profileSessionStartPolicy, &profileSessionEndPolicy, &stageType, &autoAdvanceRequiresSignal, &cancelTriggersTurnComplete, &completeTaskOnEnter, &step.WIPLimit, &pullFromStepID, &step.CreatedAt, &step.UpdatedAt) + &prompt, &eventsJSON, &allowManualMove, &isStartStep, &showInCommandPanel, &autoArchiveAfterHours, &agentProfileID, &profileSessionStartPolicy, &profileSessionEndPolicy, &stageType, &autoAdvanceRequiresSignal, &cancelTriggersTurnComplete, &completeTaskOnEnter, &step.WIPLimit, &pullFromStepID, &sessionTargetJSON, &step.CreatedAt, &step.UpdatedAt) if err != nil { return nil, err @@ -712,6 +733,11 @@ func (r *Repository) scanStep(row interface { if pullFromStepID.Valid { step.PullFromStepID = pullFromStepID.String } + if sessionTargetJSON.Valid && sessionTargetJSON.String != "" && sessionTargetJSON.String != "null" { + if err := json.Unmarshal([]byte(sessionTargetJSON.String), &step.SessionTarget); err != nil { + return nil, fmt.Errorf("failed to unmarshal session target: %w", err) + } + } if color.Valid { step.Color = color.String } @@ -727,7 +753,7 @@ func (r *Repository) scanStep(row interface { return step, nil } -const stepSelectColumns = `id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, show_in_command_panel, auto_archive_after_hours, agent_profile_id, profile_session_start_policy, profile_session_end_policy, stage_type, auto_advance_requires_signal, cancel_triggers_turn_complete, complete_task_on_enter, wip_limit, pull_from_step_id, created_at, updated_at` +const stepSelectColumns = `id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, show_in_command_panel, auto_archive_after_hours, agent_profile_id, profile_session_start_policy, profile_session_end_policy, stage_type, auto_advance_requires_signal, cancel_triggers_turn_complete, complete_task_on_enter, wip_limit, pull_from_step_id, session_target, created_at, updated_at` // GetStep retrieves a workflow step by ID. func (r *Repository) GetStep(ctx context.Context, id string) (*models.WorkflowStep, error) { @@ -758,6 +784,10 @@ func (r *Repository) UpdateStepWithDemotedStartSteps(ctx context.Context, step * step.UpdatedAt = time.Now().UTC() step.ProfileSessionStartPolicy = taskmodels.NormalizeWorkflowProfileSessionStartPolicy(string(step.ProfileSessionStartPolicy)) step.ProfileSessionEndPolicy = taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(step.ProfileSessionEndPolicy)) + sessionTargetJSON, err := marshalSessionTarget(step.SessionTarget) + if err != nil { + return nil, fmt.Errorf("failed to marshal session target: %w", err) + } eventsJSON, err := json.Marshal(step.Events) if err != nil { @@ -782,11 +812,11 @@ func (r *Repository) UpdateStepWithDemotedStartSteps(ctx context.Context, step * UPDATE workflow_steps SET name = ?, position = ?, color = ?, prompt = ?, events = ?, - allow_manual_move = ?, is_start_step = ?, show_in_command_panel = ?, auto_archive_after_hours = ?, agent_profile_id = ?, profile_session_start_policy = ?, profile_session_end_policy = ?, stage_type = ?, auto_advance_requires_signal = ?, cancel_triggers_turn_complete = ?, complete_task_on_enter = ?, wip_limit = ?, pull_from_step_id = ?, updated_at = ? + allow_manual_move = ?, is_start_step = ?, show_in_command_panel = ?, auto_archive_after_hours = ?, agent_profile_id = ?, profile_session_start_policy = ?, profile_session_end_policy = ?, stage_type = ?, auto_advance_requires_signal = ?, cancel_triggers_turn_complete = ?, complete_task_on_enter = ?, wip_limit = ?, pull_from_step_id = ?, session_target = ?, updated_at = ? WHERE id = ? `), step.Name, step.Position, step.Color, step.Prompt, string(eventsJSON), - dialect.BoolToInt(step.AllowManualMove), dialect.BoolToInt(step.IsStartStep), dialect.BoolToInt(step.ShowInCommandPanel), step.AutoArchiveAfterHours, step.AgentProfileID, taskmodels.NormalizeWorkflowProfileSessionStartPolicy(string(step.ProfileSessionStartPolicy)), taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(step.ProfileSessionEndPolicy)), normalizeStageType(step.StageType), dialect.BoolToInt(step.AutoAdvanceRequiresSignal), dialect.BoolToInt(step.CancelTriggersTurnComplete), dialect.BoolToInt(step.CompleteTaskOnEnter), step.WIPLimit, step.PullFromStepID, step.UpdatedAt, step.ID) + dialect.BoolToInt(step.AllowManualMove), dialect.BoolToInt(step.IsStartStep), dialect.BoolToInt(step.ShowInCommandPanel), step.AutoArchiveAfterHours, step.AgentProfileID, taskmodels.NormalizeWorkflowProfileSessionStartPolicy(string(step.ProfileSessionStartPolicy)), taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(step.ProfileSessionEndPolicy)), normalizeStageType(step.StageType), dialect.BoolToInt(step.AutoAdvanceRequiresSignal), dialect.BoolToInt(step.CancelTriggersTurnComplete), dialect.BoolToInt(step.CompleteTaskOnEnter), step.WIPLimit, step.PullFromStepID, sessionTargetJSON, step.UpdatedAt, step.ID) if err != nil { return nil, err } @@ -996,7 +1026,7 @@ func (r *Repository) ListStepsByWorkflow(ctx context.Context, workflowID string) func (r *Repository) ListStepsByWorkspaceID(ctx context.Context, workspaceID string) ([]*models.WorkflowStep, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT ws.id, ws.workflow_id, ws.name, ws.position, ws.color, ws.prompt, ws.events, - ws.allow_manual_move, ws.is_start_step, ws.show_in_command_panel, ws.auto_archive_after_hours, ws.agent_profile_id, ws.profile_session_start_policy, ws.profile_session_end_policy, ws.stage_type, ws.auto_advance_requires_signal, ws.cancel_triggers_turn_complete, ws.complete_task_on_enter, ws.wip_limit, ws.pull_from_step_id, ws.created_at, ws.updated_at + ws.allow_manual_move, ws.is_start_step, ws.show_in_command_panel, ws.auto_archive_after_hours, ws.agent_profile_id, ws.profile_session_start_policy, ws.profile_session_end_policy, ws.stage_type, ws.auto_advance_requires_signal, ws.cancel_triggers_turn_complete, ws.complete_task_on_enter, ws.wip_limit, ws.pull_from_step_id, ws.session_target, ws.created_at, ws.updated_at FROM workflow_steps ws JOIN workflows w ON ws.workflow_id = w.id WHERE w.workspace_id = ? diff --git a/apps/backend/internal/workflow/service/service.go b/apps/backend/internal/workflow/service/service.go index 36cec5a355..e4ea395d3d 100644 --- a/apps/backend/internal/workflow/service/service.go +++ b/apps/backend/internal/workflow/service/service.go @@ -390,6 +390,7 @@ func (s *Service) CreateStepsFromTemplate(ctx context.Context, workflowID, templ AgentProfileID: stepDef.AgentProfileID, ProfileSessionStartPolicy: taskmodels.NormalizeWorkflowProfileSessionStartPolicy(string(stepDef.ProfileSessionStartPolicy)), ProfileSessionEndPolicy: taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(stepDef.ProfileSessionEndPolicy)), + SessionTarget: models.RemapWorkflowSessionTarget(stepDef.SessionTarget, idMap), AutoAdvanceRequiresSignal: stepDef.AutoAdvanceRequiresSignal, CancelTriggersTurnComplete: stepDef.CancelTriggersTurnComplete, CompleteTaskOnEnter: stepDef.CompleteTaskOnEnter, @@ -403,6 +404,9 @@ func (s *Service) CreateStepsFromTemplate(ctx context.Context, workflowID, templ } steps = append(steps, step) } + if err := validateWorkflowSessionTargets(steps); err != nil { + return fmt.Errorf("validate template workflow session targets: %w", err) + } for _, step := range steps { if err := s.repo.CreateStep(ctx, step); err != nil { @@ -677,7 +681,7 @@ func (s *Service) ExportWorkflow(ctx context.Context, workflowID string) (*model return nil, fmt.Errorf("failed to list steps: %w", err) } stepMap := map[string][]*models.WorkflowStep{wf.ID: steps} - return models.BuildWorkflowExport([]*taskmodels.Workflow{wf}, stepMap, s.resolveProfile), nil + return models.BuildWorkflowExportWithError([]*taskmodels.Workflow{wf}, stepMap, s.resolveProfile) } // ExportWorkflows exports workflows for a workspace. When workflowIDs is nil, @@ -711,7 +715,7 @@ func (s *Service) ExportWorkflows(ctx context.Context, workspaceID string, workf } stepMap[wf.ID] = steps } - return models.BuildWorkflowExport(workflows, stepMap, s.resolveProfile), nil + return models.BuildWorkflowExportWithError(workflows, stepMap, s.resolveProfile) } // filterWorkflowsByID returns the subset of workflows whose ID is in ids, @@ -790,6 +794,9 @@ func (s *Service) importSingleWorkflow(ctx context.Context, workspaceID string, } steps = append(steps, step) } + if err := validateWorkflowSessionTargets(steps); err != nil { + return nil, fmt.Errorf("validate workflow session targets: %w", err) + } wf, err := s.workflowProvider.CreateWorkflow(ctx, workspaceID, pw.Name, pw.Description) if err != nil { @@ -852,6 +859,34 @@ func (s *Service) validateImportedStepReferences( return nil } +func validateWorkflowSessionTargets(steps []*models.WorkflowStep) error { + byID := make(map[string]*models.WorkflowStep, len(steps)) + for _, step := range steps { + if step != nil { + byID[step.ID] = step + } + } + for _, step := range steps { + if step == nil || step.SessionTarget == nil || step.SessionTarget.Kind != models.WorkflowSessionTargetStep { + continue + } + source, ok := byID[step.SessionTarget.StepID] + if !ok || source == nil { + return fmt.Errorf("step %q session target source %q was not found", step.Name, step.SessionTarget.StepID) + } + if source.WorkflowID != step.WorkflowID { + return fmt.Errorf("step %q session target source must be in the same workflow", step.Name) + } + if source.Position >= step.Position { + return fmt.Errorf("step %q session target source %q must be earlier", step.Name, source.Name) + } + if source.AgentProfileID == "" || source.SessionTarget != nil { + return fmt.Errorf("step %q session target source %q must use a direct agent profile", step.Name, source.Name) + } + } + return nil +} + // stepFromPortable builds a WorkflowStep from its portable form, remapping // position-based references to the step IDs in posToID and matching the // step-level agent profile when a matcher is wired. existingProfileID is the @@ -881,6 +916,7 @@ func (s *Service) stepFromPortableWithMatcher(workflowID string, sp models.StepP AutoArchiveAfterHours: sp.AutoArchiveAfterHours, ProfileSessionStartPolicy: taskmodels.NormalizeWorkflowProfileSessionStartPolicy(string(sp.ProfileSessionStartPolicy)), ProfileSessionEndPolicy: taskmodels.NormalizeWorkflowProfileSessionEndPolicy(string(sp.ProfileSessionEndPolicy)), + SessionTarget: sp.WorkflowSessionTarget(posToID), AutoAdvanceRequiresSignal: sp.AutoAdvanceRequiresSignal, CancelTriggersTurnComplete: sp.CancelTriggersTurnComplete, CompleteTaskOnEnter: sp.CompleteTaskOnEnter, diff --git a/apps/backend/internal/workflow/service/service_test.go b/apps/backend/internal/workflow/service/service_test.go index 3a734380b4..7e2b88e003 100644 --- a/apps/backend/internal/workflow/service/service_test.go +++ b/apps/backend/internal/workflow/service/service_test.go @@ -597,7 +597,7 @@ func TestExportWorkflow(t *testing.T) { export, err := svc.ExportWorkflow(ctx, "wf-1") require.NoError(t, err) - assert.Equal(t, models.ExportVersion, export.Version) + assert.Equal(t, models.LegacyExportVersion, export.Version) assert.Equal(t, models.ExportType, export.Type) require.Len(t, export.Workflows, 1) assert.Equal(t, "My Pipeline", export.Workflows[0].Name) diff --git a/apps/backend/internal/workflow/service/sync_apply.go b/apps/backend/internal/workflow/service/sync_apply.go index 3f2a4fce4d..e002a62ae6 100644 --- a/apps/backend/internal/workflow/service/sync_apply.go +++ b/apps/backend/internal/workflow/service/sync_apply.go @@ -159,12 +159,18 @@ func (s *Service) updateSyncedWorkflow(ctx context.Context, wf *taskmodels.Workf // Validate the complete desired step set before mutating workflow fields or // persisting any step. A malformed later step must leave the synced workflow // untouched so the next sync can retry after the source is fixed. + desiredSteps := make([]*models.WorkflowStep, 0, len(pw.Steps)) for _, sp := range pw.Steps { step := s.stepFromPortableForSync(wf.ID, sp, posToID, existingByName[sp.Name]) if err := models.ValidateWorkflowStep(step); err != nil { result.Warnings = append(result.Warnings, fmt.Sprintf("workflow %q: invalid step %q: %v", wf.Name, sp.Name, err)) return } + desiredSteps = append(desiredSteps, step) + } + if err := validateWorkflowSessionTargets(desiredSteps); err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("workflow %q: invalid workflow session targets: %v", wf.Name, err)) + return } changed, err := s.applyWorkflowFields(ctx, wf, pw) @@ -363,6 +369,7 @@ func stepMatchesDefinition(existing, desired *models.WorkflowStep) bool { existing.AgentProfileID == desired.AgentProfileID && existing.ProfileSessionStartPolicy == desired.ProfileSessionStartPolicy && existing.ProfileSessionEndPolicy == desired.ProfileSessionEndPolicy && + models.EqualWorkflowSessionTarget(existing.SessionTarget, desired.SessionTarget) && existing.WIPLimit == desired.WIPLimit && existing.PullFromStepID == desired.PullFromStepID && existing.AutoAdvanceRequiresSignal == desired.AutoAdvanceRequiresSignal && diff --git a/apps/backend/internal/workflow/service/workflow_profile_session_policy_test.go b/apps/backend/internal/workflow/service/workflow_profile_session_policy_test.go index 33e0160528..b396ff2612 100644 --- a/apps/backend/internal/workflow/service/workflow_profile_session_policy_test.go +++ b/apps/backend/internal/workflow/service/workflow_profile_session_policy_test.go @@ -21,7 +21,7 @@ func TestStepFromPortableNormalizesProfileSessionPolicies(t *testing.T) { }, map[int]string{0: "step-1"}, nil, "") require.Equal(t, taskmodels.WorkflowProfileSessionStartPolicyReuse, step.ProfileSessionStartPolicy) - require.Equal(t, taskmodels.WorkflowProfileSessionEndPolicyComplete, step.ProfileSessionEndPolicy) + require.Equal(t, taskmodels.WorkflowProfileSessionEndPolicyPark, step.ProfileSessionEndPolicy) } func TestImportWorkflowCarriesProfileSessionPoliciesOnStep(t *testing.T) { @@ -87,5 +87,5 @@ func TestApplySyncedWorkflowsNormalizesStepProfileSessionPolicies(t *testing.T) steps, err = svc.repo.ListStepsByWorkflow(context.Background(), wf.ID) require.NoError(t, err) require.Equal(t, taskmodels.WorkflowProfileSessionStartPolicyReuse, steps[0].ProfileSessionStartPolicy) - require.Equal(t, taskmodels.WorkflowProfileSessionEndPolicyComplete, steps[0].ProfileSessionEndPolicy) + require.Equal(t, taskmodels.WorkflowProfileSessionEndPolicyPark, steps[0].ProfileSessionEndPolicy) } diff --git a/apps/backend/internal/workflow/stepevents/stepevents.go b/apps/backend/internal/workflow/stepevents/stepevents.go index 8be04d49be..51b5be643c 100644 --- a/apps/backend/internal/workflow/stepevents/stepevents.go +++ b/apps/backend/internal/workflow/stepevents/stepevents.go @@ -79,6 +79,7 @@ func payload(step *models.WorkflowStep) map[string]interface{} { "wip_limit": step.WIPLimit, "pull_from_step_id": step.PullFromStepID, "agent_profile_id": step.AgentProfileID, + "session_target": models.CloneWorkflowSessionTarget(step.SessionTarget), "profile_session_start_policy": string(step.ProfileSessionStartPolicy), "profile_session_end_policy": string(step.ProfileSessionEndPolicy), "stage_type": string(step.StageType), diff --git a/apps/backend/internal/workflow/stepevents/stepevents_test.go b/apps/backend/internal/workflow/stepevents/stepevents_test.go index 51698625a6..e6f5f9db43 100644 --- a/apps/backend/internal/workflow/stepevents/stepevents_test.go +++ b/apps/backend/internal/workflow/stepevents/stepevents_test.go @@ -100,6 +100,7 @@ func TestPublishCarriesEveryStepField(t *testing.T) { "wip_limit": step.WIPLimit, "pull_from_step_id": step.PullFromStepID, "agent_profile_id": step.AgentProfileID, + "session_target": models.CloneWorkflowSessionTarget(step.SessionTarget), "profile_session_start_policy": string(step.ProfileSessionStartPolicy), "profile_session_end_policy": string(step.ProfileSessionEndPolicy), "stage_type": string(step.StageType), diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index 5383d87ad2..3dbc2b85d8 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -359,6 +359,7 @@ type BackendTemplateStep = { is_start_step?: boolean; show_in_command_panel?: boolean; agent_profile_id?: StepDefinition["agent_profile_id"]; + session_target?: StepDefinition["session_target"]; profile_session_start_policy?: WorkflowStep["profile_session_start_policy"]; profile_session_end_policy?: WorkflowStep["profile_session_end_policy"]; complete_task_on_enter?: boolean; @@ -417,6 +418,7 @@ type BackendWorkflowStep = { show_in_command_panel?: boolean; auto_archive_after_hours?: number; agent_profile_id?: string; + session_target?: WorkflowStep["session_target"]; profile_session_start_policy?: WorkflowStep["profile_session_start_policy"]; profile_session_end_policy?: WorkflowStep["profile_session_end_policy"]; complete_task_on_enter?: boolean; @@ -442,6 +444,7 @@ const transformWorkflowStep = (step: BackendWorkflowStep): WorkflowStep => ({ show_in_command_panel: step.show_in_command_panel, auto_archive_after_hours: step.auto_archive_after_hours, agent_profile_id: step.agent_profile_id, + session_target: step.session_target ?? null, profile_session_start_policy: normalizeWorkflowProfileSessionStartPolicy( step.profile_session_start_policy, ), @@ -495,6 +498,7 @@ export async function createWorkflowStepAction(payload: { is_start_step?: boolean; show_in_command_panel?: boolean; agent_profile_id?: string; + session_target?: WorkflowStep["session_target"]; allow_manual_move?: boolean; auto_advance_requires_signal?: boolean; wip_limit?: number; @@ -515,6 +519,7 @@ export async function createWorkflowStepAction(payload: { is_start_step: payload.is_start_step ?? false, show_in_command_panel: payload.show_in_command_panel ?? true, agent_profile_id: payload.agent_profile_id, + session_target: payload.session_target, wip_limit: payload.wip_limit ?? 0, pull_from_step_id: payload.pull_from_step_id ?? "", stage_type: payload.stage_type, @@ -546,6 +551,7 @@ export async function updateWorkflowStepAction( | "show_in_command_panel" | "auto_archive_after_hours" | "agent_profile_id" + | "session_target" | "auto_advance_requires_signal" | "cancel_triggers_turn_complete" | "wip_limit" diff --git a/apps/web/app/settings/workspace/workflow-duplication.test.ts b/apps/web/app/settings/workspace/workflow-duplication.test.ts index 774aa7106b..82db6d3843 100644 --- a/apps/web/app/settings/workspace/workflow-duplication.test.ts +++ b/apps/web/app/settings/workspace/workflow-duplication.test.ts @@ -71,6 +71,7 @@ function sourceSteps(): WorkflowStep[] { show_in_command_panel: false, auto_archive_after_hours: 0, agent_profile_id: "", + session_target: { kind: "step", step_id: REVIEW_STEP_ID }, complete_task_on_enter: true, auto_advance_requires_signal: false, cancel_triggers_turn_complete: false, @@ -170,6 +171,7 @@ describe("createWorkflowDuplication", () => { }); expect(copiedDone.complete_task_on_enter).toBe(true); expect(copiedDone.pull_from_step_id).toBe(copiedReview.id); + expect(copiedDone.session_target).toEqual({ kind: "step", step_id: copiedReview.id }); expect(copiedReview.events?.on_turn_complete).toEqual([ { type: "move_to_step", diff --git a/apps/web/app/settings/workspace/workflow-duplication.ts b/apps/web/app/settings/workspace/workflow-duplication.ts index b0f6b12a18..99426fbf20 100644 --- a/apps/web/app/settings/workspace/workflow-duplication.ts +++ b/apps/web/app/settings/workspace/workflow-duplication.ts @@ -89,6 +89,10 @@ function cloneWorkflowStep( auto_advance_requires_signal: source.auto_advance_requires_signal, cancel_triggers_turn_complete: source.cancel_triggers_turn_complete, wip_limit: source.wip_limit, + session_target: + source.session_target?.kind === "step" + ? { kind: "step", step_id: remapStepId(source.session_target.step_id)! } + : (source.session_target ?? null), pull_from_step_id: remapStepId(source.pull_from_step_id), stage_type: source.stage_type, created_at: "", diff --git a/apps/web/app/settings/workspace/workspace-workflows-client.tsx b/apps/web/app/settings/workspace/workspace-workflows-client.tsx index a1e5a097da..2fe5e1485b 100644 --- a/apps/web/app/settings/workspace/workspace-workflows-client.tsx +++ b/apps/web/app/settings/workspace/workspace-workflows-client.tsx @@ -21,6 +21,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { SettingsSection } from "@/components/settings/settings-section"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; import { WorkflowCard } from "@/components/settings/workflow-card"; import { WorkflowSectionActions } from "@/components/settings/workflow-section-actions"; import { WorkflowSyncSection } from "@/components/settings/workflow-sync-section"; @@ -322,10 +323,16 @@ function SortableWorkflowItem({ readOnly?: boolean; children: React.ReactNode; }) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: workflow.id, - disabled: readOnly, - }); + const { t } = useTranslation(); + const { + attributes, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: workflow.id, disabled: readOnly }); const style = { transform: CSS.Transform.toString(transform), transition, @@ -341,14 +348,26 @@ function SortableWorkflowItem({ data-testid={`workflow-order-item-${workflow.id}`} > {!readOnly && ( -
- -
+ + + + + + + {t("workflows:reorderWorkflow", { name: workflow.name })} + + + )} {children} @@ -392,7 +411,7 @@ function WorkflowList({ strategy={verticalListSortingStrategy} >
0} data-settings-dirty-level="container" data-testid="workflow-order-list" diff --git a/apps/web/components/settings/use-workflow-draft-contributor.ts b/apps/web/components/settings/use-workflow-draft-contributor.ts index 94fca79c72..4294cc65f4 100644 --- a/apps/web/components/settings/use-workflow-draft-contributor.ts +++ b/apps/web/components/settings/use-workflow-draft-contributor.ts @@ -14,16 +14,19 @@ import { remapWorkflowDraftSteps, } from "./workflow-card-actions"; import type { useWorkflowMutationGuard } from "./workflow-mutation-guard"; +import { hasInvalidWorkflowSessionTargets } from "./workflow-session-target-validation"; const TEMP_WORKFLOW_PREFIX = "temp-workflow-"; function workflowSaveInvalidReason( workflowName: string, modelConfigResolutionPending: boolean, + invalidSessionTargets: boolean, translate: (key: string) => string, ): string | undefined { if (!workflowName.trim()) return translate("workflows:workflowNameIsRequired"); if (modelConfigResolutionPending) return translate("agents:resolvingModelOptions"); + if (invalidSessionTargets) return translate("workflows:workflowSessionTargetMustBeRepaired"); return undefined; } @@ -125,14 +128,23 @@ export function useWorkflowDraftContributor(args: WorkflowDraftContributorArgs) const removingDraftRef = useRef(false); const [isRemovingDraft, setIsRemovingDraft] = useState(false); const stepsDirty = !areStepDraftsEqual(workflowSteps, savedWorkflowSteps); + const invalidSessionTargets = hasInvalidWorkflowSessionTargets(workflowSteps); useSettingsSaveContributor({ id: `workflow:${workflow.id}`, order: 100, revision: persistence.revision, isDirty: args.isWorkflowDirty || stepsDirty, - canSave: workflow.name.trim().length > 0 && !isSessionConfigResolutionPending, - invalidReason: workflowSaveInvalidReason(workflow.name, isSessionConfigResolutionPending, t), + canSave: + workflow.name.trim().length > 0 && + !isSessionConfigResolutionPending && + !invalidSessionTargets, + invalidReason: workflowSaveInvalidReason( + workflow.name, + isSessionConfigResolutionPending, + invalidSessionTargets, + t, + ), save: async (submittedRevision) => { if (!workflow.id.startsWith(TEMP_WORKFLOW_PREFIX)) { await persistence.persistSubmittedDraft(submittedRevision); diff --git a/apps/web/components/settings/workflow-card-actions-dependency-ordering.test.ts b/apps/web/components/settings/workflow-card-actions-dependency-ordering.test.ts new file mode 100644 index 0000000000..8f61ca4298 --- /dev/null +++ b/apps/web/components/settings/workflow-card-actions-dependency-ordering.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { updateWorkflowAction, updateWorkflowStepAction } from "@/app/actions/workspaces"; +import type { Workflow, WorkflowStep } from "@/lib/types/http"; +import { createWorkflowDraftSaveProgress, persistWorkflowDraft } from "./workflow-card-actions"; + +vi.mock("@/app/actions/workspaces", () => ({ + bulkMoveTasks: vi.fn(), + createWorkflowAction: vi.fn(), + createWorkflowStepAction: vi.fn(), + deleteWorkflowStepAction: vi.fn(), + getStepTaskCount: vi.fn(), + getWorkflowTaskCount: vi.fn(), + listWorkflowStepsAction: vi.fn(), + reorderWorkflowStepsAction: vi.fn(), + updateWorkflowAction: vi.fn(), + updateWorkflowStepAction: vi.fn(), +})); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(updateWorkflowStepAction).mockImplementation(async (_stepID, payload) => { + if (payload.session_target != null && payload.agent_profile_id) { + throw new Error("session_target cannot be combined with agent_profile_id"); + } + return {} as WorkflowStep; + }); +}); + +const workflow = { + id: "wf-1", + workspace_id: "ws-1", + name: "Workflow", + created_at: "", + updated_at: "", +} as Workflow; +const SOURCE_PROFILE_ID = "profile-source"; + +function step(id: string, name: string, position: number, isStartStep: boolean): WorkflowStep { + return { + id, + workflow_id: workflow.id, + name, + position, + color: "bg-slate-500", + allow_manual_move: true, + is_start_step: isStartStep, + created_at: "", + updated_at: "", + }; +} + +describe("persistWorkflowDraft dependency ordering", () => { + it("repairs dependent targets before persisting a source profile change", async () => { + vi.mocked(updateWorkflowAction).mockResolvedValue(workflow); + const sourceSaved = { ...step("source", "Implement", 0, true), agent_profile_id: "profile-a" }; + const dependentSaved = { + ...step("dependent", "Review", 1, false), + session_target: { kind: "step" as const, step_id: "source" }, + }; + await persistWorkflowDraft({ + workflow, + draftSteps: [ + { ...sourceSaved, agent_profile_id: "" }, + { ...dependentSaved, session_target: null }, + ], + savedSteps: [sourceSaved, dependentSaved], + progress: createWorkflowDraftSaveProgress(), + }); + expect(vi.mocked(updateWorkflowStepAction).mock.calls[0]).toEqual([ + "dependent", + { session_target: null }, + ]); + expect(updateWorkflowStepAction).toHaveBeenCalledWith( + "source", + expect.objectContaining({ agent_profile_id: "" }), + ); + }); + + it("clears an existing direct profile with a newly attached target", async () => { + vi.mocked(updateWorkflowAction).mockResolvedValue(workflow); + const saved = { + ...step("step-review", "Review", 1, false), + agent_profile_id: "profile-review", + session_target: null, + }; + const draft = { ...saved, agent_profile_id: "", session_target: { kind: "initial" as const } }; + await persistWorkflowDraft({ + workflow, + draftSteps: [draft], + savedSteps: [saved], + progress: createWorkflowDraftSaveProgress(), + }); + expect(updateWorkflowStepAction).toHaveBeenCalledOnce(); + expect(updateWorkflowStepAction).toHaveBeenCalledWith( + "step-review", + expect.objectContaining({ session_target: { kind: "initial" }, agent_profile_id: "" }), + ); + }); +}); + +describe("persistWorkflowDraft dependency ordering", () => { + it("persists a newly enabled source profile before attaching its target", async () => { + vi.mocked(updateWorkflowAction).mockResolvedValue(workflow); + const sourceSaved = { ...step("source", "Implement", 0, true), agent_profile_id: "" }; + const dependentSaved = { ...step("dependent", "Review", 1, false), session_target: null }; + const sourceDraft = { ...sourceSaved, agent_profile_id: SOURCE_PROFILE_ID }; + const dependentDraft = { + ...dependentSaved, + session_target: { kind: "step" as const, step_id: "source" }, + }; + await persistWorkflowDraft({ + workflow, + draftSteps: [sourceDraft, dependentDraft], + savedSteps: [sourceSaved, dependentSaved], + progress: createWorkflowDraftSaveProgress(), + }); + const calls = vi.mocked(updateWorkflowStepAction).mock.calls; + expect(calls[0]).toEqual([ + "source", + expect.objectContaining({ agent_profile_id: SOURCE_PROFILE_ID }), + ]); + expect(calls.at(-1)).toEqual([ + "dependent", + expect.objectContaining({ + agent_profile_id: "", + session_target: { kind: "step", step_id: "source" }, + }), + ]); + }); + + it("detaches an old target before enabling its new source profile", async () => { + vi.mocked(updateWorkflowAction).mockResolvedValue(workflow); + const sourceSaved = { ...step("source", "Implement", 0, true), agent_profile_id: "" }; + const oldSourceSaved = { + ...step("old-source", "Plan", 1, false), + agent_profile_id: "profile-old", + }; + const dependentSaved = { + ...step("dependent", "Review", 2, false), + session_target: { kind: "step" as const, step_id: "old-source" }, + }; + const sourceDraft = { ...sourceSaved, agent_profile_id: SOURCE_PROFILE_ID }; + const dependentDraft = { + ...dependentSaved, + session_target: { kind: "step" as const, step_id: "source" }, + }; + await persistWorkflowDraft({ + workflow, + draftSteps: [sourceDraft, oldSourceSaved, dependentDraft], + savedSteps: [sourceSaved, oldSourceSaved, dependentSaved], + progress: createWorkflowDraftSaveProgress(), + }); + const calls = vi.mocked(updateWorkflowStepAction).mock.calls; + expect(calls[0]).toEqual(["dependent", { session_target: null }]); + expect(calls[1]).toEqual([ + "source", + expect.objectContaining({ agent_profile_id: SOURCE_PROFILE_ID }), + ]); + expect(calls.at(-1)).toEqual([ + "dependent", + expect.objectContaining({ session_target: { kind: "step", step_id: "source" } }), + ]); + }); +}); diff --git a/apps/web/components/settings/workflow-card-actions.ts b/apps/web/components/settings/workflow-card-actions.ts index 3082b67c21..f44f6f71d2 100644 --- a/apps/web/components/settings/workflow-card-actions.ts +++ b/apps/web/components/settings/workflow-card-actions.ts @@ -25,7 +25,6 @@ import { listWorkflowStepsAction, getStepTaskCount, getWorkflowTaskCount, - exportWorkflowAction, bulkMoveTasks, } from "@/app/actions/workspaces"; @@ -126,6 +125,19 @@ function createRemoveWorkflowStepHandler( const isNewWorkflow = params.isNewWorkflow ?? workflow.id.startsWith(TEMP_WORKFLOW_PREFIX); return async (stepId: string) => { if (readOnly) return; + const dependentStep = workflowSteps.find( + (step) => step.session_target?.kind === "step" && step.session_target.step_id === stepId, + ); + if (dependentStep) { + toast({ + title: t("workflows:sessionTargetDependentMustBeRepaired"), + description: t("workflows:sessionTargetDependentRepairDescription", { + stepName: dependentStep.name, + }), + variant: "error", + }); + return; + } const proposedSteps = workflowSteps .filter((step) => step.id !== stepId) .map((step, position) => ({ ...step, position })); @@ -335,6 +347,7 @@ async function createMissingSteps( profile_session_end_policy: normalizeWorkflowProfileSessionEndPolicy( step.profile_session_end_policy, ), + session_target: undefined, auto_advance_requires_signal: step.auto_advance_requires_signal ?? false, }); mappings.set(step.id, created.id); @@ -343,12 +356,53 @@ async function createMissingSteps( async function updateChangedSteps(remapped: WorkflowStep[], savedSteps: WorkflowStep[]) { const savedById = new Map(savedSteps.map((step) => [step.id, step])); - for (const step of remapped) { + const changed = remapped.filter((step) => { const saved = savedById.get(step.id); - if (!saved || !areStepDraftsEqual(step, saved)) { + return !saved || !areStepDraftsEqual(step, saved); + }); + + const targetChanged = (step: WorkflowStep, saved: WorkflowStep) => + JSON.stringify(step.session_target ?? null) !== JSON.stringify(saved.session_target ?? null); + const targetNeedsSourceProfileFirst = (step: WorkflowStep) => { + const target = step.session_target; + if (target?.kind !== "step") return false; + const sourceDraft = remapped.find((candidate) => candidate.id === target.step_id); + const sourceSaved = sourceDraft ? savedById.get(sourceDraft.id) : undefined; + return sourceSaved?.agent_profile_id === "" && sourceDraft?.agent_profile_id !== ""; + }; + + // Existing targets must be detached before their source profile is removed. + // A replacement target also clears the mutually-exclusive direct profile in + // the same request, so the controller never observes an invalid half-state. + for (const step of changed) { + const saved = savedById.get(step.id); + if (!saved || !targetChanged(step, saved) || saved.session_target == null) continue; + const attachAfterSourceProfile = targetNeedsSourceProfileFirst(step); + await updateWorkflowStepAction(step.id, { + session_target: attachAfterSourceProfile ? null : (step.session_target ?? null), + ...(!attachAfterSourceProfile && step.session_target != null ? { agent_profile_id: "" } : {}), + }); + } + + // A newly attached target can refer to a source whose direct profile is also + // being enabled in this save. Persist all other changes first, then attach + // those targets once the source profile is valid on the server. + const targetAdditions = changed.filter((step) => { + const saved = savedById.get(step.id); + return ( + (saved?.session_target == null && step.session_target != null) || + targetNeedsSourceProfileFirst(step) + ); + }); + const targetAdditionIDs = new Set(targetAdditions.map((step) => step.id)); + for (const step of changed) { + if (!targetAdditionIDs.has(step.id)) { await updateWorkflowStepAction(step.id, stepUpdatePayload(step)); } } + for (const step of targetAdditions) { + await updateWorkflowStepAction(step.id, stepUpdatePayload(step)); + } } function mapTemplateStepIds( @@ -380,6 +434,10 @@ function remapDraftStep( workflow_id: persistedWorkflowId as WorkflowStep["workflow_id"], position, pull_from_step_id: remapId(step.pull_from_step_id), + session_target: + step.session_target?.kind === "step" + ? { kind: "step", step_id: remapId(step.session_target.step_id)! } + : (step.session_target ?? null), events: remapStepReferences(step.events, mappings), }; } @@ -594,29 +652,3 @@ export function useStepDeleteHandlers({ return { handleMigrateAndDeleteStep, handleDeleteStepAndTasks }; } - -type WorkflowExportActionsParams = { - workflowId: string; - setExportYaml: (yaml: string) => void; - setExportOpen: (open: boolean) => void; - toast: ReturnType["toast"]; -}; - -export async function handleExportWorkflow({ - workflowId, - setExportYaml, - setExportOpen, - toast, -}: WorkflowExportActionsParams) { - try { - const yamlText = await exportWorkflowAction(workflowId); - setExportYaml(yamlText); - setExportOpen(true); - } catch (error) { - toast({ - title: t("workflows:failedToExportWorkflow"), - description: fallbackErrorMessage(error), - variant: "error", - }); - } -} diff --git a/apps/web/components/settings/workflow-card-header-actions.tsx b/apps/web/components/settings/workflow-card-header-actions.tsx index 8a5cc8c714..0b1b6c8a37 100644 --- a/apps/web/components/settings/workflow-card-header-actions.tsx +++ b/apps/web/components/settings/workflow-card-header-actions.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useToast } from "@/components/toast-provider"; -import { handleExportWorkflow } from "./workflow-card-actions"; +import { handleExportWorkflow } from "./workflow-export-actions"; type WorkflowCardHeaderActionsProps = { workflowId: string; diff --git a/apps/web/components/settings/workflow-export-actions.ts b/apps/web/components/settings/workflow-export-actions.ts new file mode 100644 index 0000000000..4856aa56a4 --- /dev/null +++ b/apps/web/components/settings/workflow-export-actions.ts @@ -0,0 +1,31 @@ +"use client"; + +import { useToast } from "@/components/toast-provider"; +import { exportWorkflowAction } from "@/app/actions/workspaces"; +import { t } from "@/lib/i18n"; + +type WorkflowExportActionsParams = { + workflowId: string; + setExportYaml: (yaml: string) => void; + setExportOpen: (open: boolean) => void; + toast: ReturnType["toast"]; +}; + +export async function handleExportWorkflow({ + workflowId, + setExportYaml, + setExportOpen, + toast, +}: WorkflowExportActionsParams) { + try { + const yamlText = await exportWorkflowAction(workflowId); + setExportYaml(yamlText); + setExportOpen(true); + } catch (error) { + toast({ + title: t("workflows:failedToExportWorkflow"), + description: error instanceof Error ? error.message : t("common:requestFailed"), + variant: "error", + }); + } +} diff --git a/apps/web/components/settings/workflow-pipeline-editor-panels.tsx b/apps/web/components/settings/workflow-pipeline-editor-panels.tsx index 5f645c8126..da0ef17861 100644 --- a/apps/web/components/settings/workflow-pipeline-editor-panels.tsx +++ b/apps/web/components/settings/workflow-pipeline-editor-panels.tsx @@ -35,9 +35,11 @@ import { WorkflowStepAgentProfileSelector } from "./workflow-step-agent-profile- type StepConfigHeaderProps = { step: WorkflowStep; savedStep?: WorkflowStep; + steps: WorkflowStep[]; localName: string; onLocalNameChange: (name: string) => void; onUpdate: (updates: Partial) => void; + onRestoreSource?: () => void; onRemove: () => void; readOnly: boolean; debouncedUpdateName: (name: string) => void; @@ -46,9 +48,11 @@ type StepConfigHeaderProps = { function StepConfigHeader({ step, savedStep, + steps, localName, onLocalNameChange, onUpdate, + onRestoreSource, onRemove, readOnly, debouncedUpdateName, @@ -98,7 +102,9 @@ function StepConfigHeader({ ) => void; onRemove: () => void; + onRestoreSourceStep?: (sourceStepId: string) => void; readOnly?: boolean; onSessionConfigResolutionPendingChange?: (pending: boolean) => void; }; @@ -471,6 +478,7 @@ export function StepConfigPanel({ steps, onUpdate, onRemove, + onRestoreSourceStep, readOnly = false, onSessionConfigResolutionPendingChange, }: StepConfigPanelProps) { @@ -485,6 +493,7 @@ export function StepConfigPanel({ }, 500); const actions = useStepActions({ step, onUpdate }); + const sourceTarget = step.session_target?.kind === "step" ? step.session_target : undefined; const isFinalStep = steps[steps.length - 1]?.id === step.id; return ( @@ -498,6 +507,12 @@ export function StepConfigPanel({ onRestoreSourceStep(sourceTarget.step_id) + : undefined + } localName={localName} onLocalNameChange={setLocalName} onUpdate={onUpdate} diff --git a/apps/web/components/settings/workflow-pipeline-editor.tsx b/apps/web/components/settings/workflow-pipeline-editor.tsx index 5a7131122e..5ac7fd9ab6 100644 --- a/apps/web/components/settings/workflow-pipeline-editor.tsx +++ b/apps/web/components/settings/workflow-pipeline-editor.tsx @@ -34,6 +34,7 @@ import { cn } from "@/lib/utils"; import { StepConfigPanel } from "./workflow-pipeline-editor-panels"; import { isWorkflowStepDirty } from "./workflow-dirty-state"; import { WorkflowCycleDiagnostic } from "./workflow-cycle-diagnostic"; +import { getWorkflowSessionTargetIssue } from "./workflow-session-target-validation"; type WorkflowPipelineEditorProps = { steps: WorkflowStep[]; @@ -72,16 +73,28 @@ type PipelineNodeProps = { step: WorkflowStep; isDirty: boolean; isReplayCycleAffected: boolean; + isInvalidSessionTarget: boolean; isSelected: boolean; onSelect: () => void; onRemove: () => void; readOnly?: boolean; }; +function InvalidSessionTargetIndicator() { + const { t } = useTranslation(); + return ( + + ); +} + function PipelineNode({ step, isDirty, isReplayCycleAffected, + isInvalidSessionTarget, isSelected, onSelect, onRemove, @@ -109,6 +122,7 @@ function PipelineNode({ onClick={onSelect} data-settings-dirty={isDirty} data-workflow-replay-cycle={isReplayCycleAffected} + data-workflow-invalid-session-target={isInvalidSessionTarget} data-settings-dirty-level="container" data-testid={`workflow-step-node-${step.id}`} > @@ -124,6 +138,7 @@ function PipelineNode({ )} + {isInvalidSessionTarget && } + ))} + +
+ + {t("workflows:profileSessionEndHeading")} + + {END_OPTIONS.map((option) => ( + + ))} +
+
+ ); +} + +function LifecycleNavigation({ + step, + onOpen, +}: Pick & { onOpen: () => void }) { + const { t } = useTranslation(); + return ( + + ); +} + +export function SelectorSurface({ + step, + steps, + profiles, + readOnly, + profileSelectionDisabled, + view, + setView, + onUpdate, + onSelectProfile, + onSelectTarget, +}: WorkflowSessionSelectorSurfaceProps) { + const { t } = useTranslation(); + if (view === "session") { + return ( +
+
+ +
+

{t(PROFILE_SESSION_LIFECYCLE_KEY)}

+

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

+
+
+
+ onUpdate({ profile_session_start_policy: policy })} + onEndSelect={(policy) => onUpdate({ profile_session_end_policy: policy })} + /> +
+
+ ); + } + + return ( +
+
+ setView("session")} /> +
+
+ +
+
+ ); +} + +export function MobileSelectorSurface({ + step, + steps, + profiles, + readOnly, + profileSelectionDisabled, + view, + setView, + onUpdate, + onSelectProfile, + onSelectTarget, + onClose, +}: WorkflowSessionSelectorSurfaceProps) { + const { t } = useTranslation(); + if (view === "session") { + return ( + !open && onClose()} + title={t(PROFILE_SESSION_LIFECYCLE_KEY)} + description={t("workflows:profileSessionLifecycleIntro")} + contentTestId={step.id + "-profile-session-lifecycle-content"} + fixedContent={ +
+ +
+ } + > + onUpdate({ profile_session_start_policy: policy })} + onEndSelect={(policy) => onUpdate({ profile_session_end_policy: policy })} + /> +
+ ); + } + + return ( + !open && onClose()} + title={t("workflows:agentProfile")} + description={t("workflows:sessionTargetAndProfileDescription")} + contentTestId={step.id + "-profile-picker-content"} + fixedContent={ +
+ setView("session")} /> +
+ } + > + +
+ ); +} diff --git a/apps/web/components/settings/workflow-session-target-validation.test.ts b/apps/web/components/settings/workflow-session-target-validation.test.ts new file mode 100644 index 0000000000..34d1b2d5a3 --- /dev/null +++ b/apps/web/components/settings/workflow-session-target-validation.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import type { WorkflowStep } from "@/lib/types/http"; +import { + getWorkflowSessionTargetIssue, + hasInvalidWorkflowSessionTargets, +} from "./workflow-session-target-validation"; + +function step(id: string, position: number, overrides: Partial = {}): WorkflowStep { + return { + id, + workflow_id: "workflow-1" as WorkflowStep["workflow_id"], + name: id, + position, + color: "bg-blue-500", + created_at: "", + updated_at: "", + ...overrides, + }; +} + +describe("workflow session target validation", () => { + it("accepts an earlier direct-profile source", () => { + const source = step("source", 0, { agent_profile_id: "profile-a" }); + const destination = step("destination", 1, { + session_target: { kind: "step", step_id: source.id }, + }); + + expect(getWorkflowSessionTargetIssue(destination, [source, destination])).toBeUndefined(); + }); + + it.each([ + { + name: "missing source", + destination: step("destination", 1, { + session_target: { kind: "step", step_id: "gone" }, + }), + steps: [], + kind: "missing-source", + }, + { + name: "source after destination", + destination: step("destination", 1, { + session_target: { kind: "step", step_id: "source" }, + }), + steps: [step("source", 2, { agent_profile_id: "profile-a" })], + kind: "not-earlier", + }, + { + name: "source without profile", + destination: step("destination", 1, { + session_target: { kind: "step", step_id: "source" }, + }), + steps: [step("source", 0)], + kind: "missing-profile", + }, + { + name: "indirect source", + destination: step("destination", 1, { + session_target: { kind: "step", step_id: "source" }, + }), + steps: [ + step("source", 0, { + agent_profile_id: "profile-a", + session_target: { kind: "initial" }, + }), + ], + kind: "indirect-source", + }, + ])("reports $name", ({ destination, steps, kind }) => { + expect(getWorkflowSessionTargetIssue(destination, [destination, ...steps])).toMatchObject({ + kind, + }); + }); + + it("detects any invalid target before a workflow save", () => { + const source = step("source", 0); + const destination = step("destination", 1, { + session_target: { kind: "step", step_id: source.id }, + }); + + expect(hasInvalidWorkflowSessionTargets([source, destination])).toBe(true); + }); +}); diff --git a/apps/web/components/settings/workflow-session-target-validation.ts b/apps/web/components/settings/workflow-session-target-validation.ts new file mode 100644 index 0000000000..a6873db535 --- /dev/null +++ b/apps/web/components/settings/workflow-session-target-validation.ts @@ -0,0 +1,37 @@ +import type { WorkflowStep } from "@/lib/types/http"; + +export type WorkflowSessionTargetIssueKind = + | "missing-source" + | "not-earlier" + | "missing-profile" + | "indirect-source"; + +export type WorkflowSessionTargetIssue = { + kind: WorkflowSessionTargetIssueKind; + sourceStepId?: string; +}; + +export function getWorkflowSessionTargetIssue( + step: WorkflowStep, + steps: readonly WorkflowStep[], +): WorkflowSessionTargetIssue | undefined { + const target = step.session_target; + if (!target || target.kind !== "step") return undefined; + + const source = steps.find((candidate) => candidate.id === target.step_id); + if (!source) return { kind: "missing-source", sourceStepId: target.step_id }; + if (source.position >= step.position) { + return { kind: "not-earlier", sourceStepId: source.id }; + } + if (!source.agent_profile_id) { + return { kind: "missing-profile", sourceStepId: source.id }; + } + if (source.session_target) { + return { kind: "indirect-source", sourceStepId: source.id }; + } + return undefined; +} + +export function hasInvalidWorkflowSessionTargets(steps: readonly WorkflowStep[]): boolean { + return steps.some((step) => getWorkflowSessionTargetIssue(step, steps) !== undefined); +} diff --git a/apps/web/components/settings/workflow-step-agent-profile-selector.test.tsx b/apps/web/components/settings/workflow-step-agent-profile-selector.test.tsx index c33a8c46ae..1895013aea 100644 --- a/apps/web/components/settings/workflow-step-agent-profile-selector.test.tsx +++ b/apps/web/components/settings/workflow-step-agent-profile-selector.test.tsx @@ -7,6 +7,8 @@ const breakpoint = { isMobile: false }; const ARIA_PRESSED = "aria-pressed"; const ARIA_TRUE = "true"; const START_NEW_TEST_ID = "step-1-profile-session-start-new"; +const LIFECYCLE_TEST_ID = "step-1-profile-session-lifecycle-select"; +const SOURCE_TARGET_TEST_ID = "step-2-session-target-step-step-1"; vi.mock("@/hooks/use-responsive-breakpoint", () => ({ useResponsiveBreakpoint: () => breakpoint, @@ -56,13 +58,18 @@ beforeEach(() => { afterEach(cleanup); -function renderSelector(overrides: Partial = {}, readOnly = false) { +function renderSelector( + overrides: Partial = {}, + readOnly = false, + steps: WorkflowStep[] = [], +) { const onUpdate = vi.fn(); const currentStep = { ...step, ...overrides }; render( , @@ -88,7 +95,7 @@ describe("WorkflowStepAgentProfileSelector", () => { const { onUpdate, trigger } = renderSelector(); fireEvent.click(trigger); - fireEvent.click(screen.getByTestId("step-1-profile-session-lifecycle-select")); + fireEvent.click(screen.getByTestId(LIFECYCLE_TEST_ID)); expect(screen.getByText("When this step starts:")).toBeTruthy(); expect(screen.getByText("When this step ends:")).toBeTruthy(); expect( @@ -105,16 +112,30 @@ describe("WorkflowStepAgentProfileSelector", () => { expect(onUpdate).toHaveBeenNthCalledWith(2, { profile_session_end_policy: "park" }); }); + it("selects park on end when the lifecycle policy is unset", () => { + const { trigger } = renderSelector({ profile_session_end_policy: undefined }); + + fireEvent.click(trigger); + fireEvent.click(screen.getByTestId(LIFECYCLE_TEST_ID)); + + expect(screen.getByTestId("step-1-profile-session-end-park").getAttribute(ARIA_PRESSED)).toBe( + ARIA_TRUE, + ); + expect( + screen.getByTestId("step-1-profile-session-end-complete").getAttribute(ARIA_PRESSED), + ).toBe("false"); + }); + it("renders the same nested surface in the mobile drawer", () => { breakpoint.isMobile = true; const { trigger } = renderSelector({ profile_session_start_policy: "new", - profile_session_end_policy: "park", + profile_session_end_policy: undefined, }); fireEvent.click(trigger); expect(screen.getByRole("heading", { name: "Agent Profile" })).toBeTruthy(); - fireEvent.click(screen.getByTestId("step-1-profile-session-lifecycle-select")); + fireEvent.click(screen.getByTestId(LIFECYCLE_TEST_ID)); expect(screen.getByRole("heading", { name: "Session lifecycle" })).toBeTruthy(); expect(screen.getByTestId(START_NEW_TEST_ID).getAttribute(ARIA_PRESSED)).toBe(ARIA_TRUE); expect(screen.getByTestId("step-1-profile-session-end-park").getAttribute(ARIA_PRESSED)).toBe( @@ -122,19 +143,6 @@ describe("WorkflowStepAgentProfileSelector", () => { ); }); - it("keeps the current profile and policy visible while read-only", () => { - const { trigger } = renderSelector( - { profile_session_start_policy: "new", profile_session_end_policy: "park" }, - true, - ); - - expect((trigger as HTMLButtonElement).disabled).toBe(true); - expect(trigger.textContent).toContain("Codex • Fast"); - expect(trigger.textContent).toContain("New on start"); - expect(trigger.textContent).toContain("Park on end"); - expect(screen.getByTestId("agent-logo-codex")).toBeTruthy(); - }); - it("keeps lifecycle editing available when conditional session settings are configured", () => { const { onUpdate, trigger } = renderSelector({ events: { on_enter: [{ type: "configure_session", config: { rules: [] } }] }, @@ -147,9 +155,129 @@ describe("WorkflowStepAgentProfileSelector", () => { screen.getByTestId("step-1-profile-option-profile-b").getAttribute("data-disabled"), ).toBe("true"); - fireEvent.click(screen.getByTestId("step-1-profile-session-lifecycle-select")); + fireEvent.click(screen.getByTestId(LIFECYCLE_TEST_ID)); expect((screen.getByTestId(START_NEW_TEST_ID) as HTMLButtonElement).disabled).toBe(false); fireEvent.click(screen.getByTestId(START_NEW_TEST_ID)); expect(onUpdate).toHaveBeenCalledWith({ profile_session_start_policy: "new" }); }); }); + +describe("read-only workflow selector", () => { + it("keeps the current profile and policy visible while read-only", () => { + const { trigger } = renderSelector( + { profile_session_start_policy: "new", profile_session_end_policy: "park" }, + true, + ); + expect((trigger as HTMLButtonElement).disabled).toBe(false); + expect(trigger.textContent).toContain("Codex • Fast"); + expect(trigger.textContent).toContain("New on start"); + expect(trigger.textContent).toContain("Park on end"); + expect(screen.getByTestId("agent-logo-codex")).toBeTruthy(); + }); + + it("allows inspection while disabling selector mutations", () => { + const { onUpdate, trigger } = renderSelector(undefined, true); + fireEvent.click(trigger); + expect( + screen.getByTestId("step-1-profile-option-profile-b").getAttribute("data-disabled"), + ).toBe("true"); + fireEvent.click(screen.getByTestId(LIFECYCLE_TEST_ID)); + expect((screen.getByTestId(START_NEW_TEST_ID) as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(screen.getByTestId(START_NEW_TEST_ID)); + expect(onUpdate).not.toHaveBeenCalled(); + }); + + it("keeps repair inspection available while disabling read-only repairs", () => { + const source = { ...step, position: 2 } as WorkflowStep; + const destination = { + ...step, + id: "step-2", + position: 1, + agent_profile_id: "", + session_target: { kind: "step", step_id: "step-1" }, + } as WorkflowStep; + const { trigger } = renderSelector(destination, true, [source, destination]); + expect( + (screen.getByTestId("workflow-session-target-clear") as HTMLButtonElement).disabled, + ).toBe(true); + fireEvent.click(screen.getByTestId("workflow-session-target-choose-another")); + expect(screen.getByPlaceholderText("Search agent profiles...")).toBeTruthy(); + expect(trigger).toBeTruthy(); + }); +}); + +describe("workflow session targets", () => { + it("selects the initial workflow session and clears the profile override", () => { + const { onUpdate, trigger } = renderSelector(); + + fireEvent.click(trigger); + fireEvent.click(screen.getByTestId("step-1-session-target-initial")); + + expect(onUpdate).toHaveBeenCalledWith({ + session_target: { kind: "initial" }, + agent_profile_id: "", + }); + }); + + it("offers earlier direct-profile steps as session targets", () => { + const destination = { + ...step, + id: "step-2", + name: "Review", + position: 1, + agent_profile_id: "", + session_target: null, + } as WorkflowStep; + + const { onUpdate, trigger } = renderSelector(destination, false, [step, destination]); + + fireEvent.click(trigger); + expect(screen.getByTestId(SOURCE_TARGET_TEST_ID)).toBeTruthy(); + fireEvent.click(screen.getByTestId(SOURCE_TARGET_TEST_ID)); + + expect(onUpdate).toHaveBeenCalledWith({ + session_target: { kind: "step", step_id: "step-1" }, + agent_profile_id: "", + }); + }); + + it("keeps target choices available in the mobile picker", () => { + breakpoint.isMobile = true; + const destination = { + ...step, + id: "step-2", + position: 1, + agent_profile_id: "", + session_target: null, + } as WorkflowStep; + const { onUpdate, trigger } = renderSelector(destination, false, [step, destination]); + + fireEvent.click(trigger); + expect(screen.getByTestId(SOURCE_TARGET_TEST_ID)).toBeTruthy(); + fireEvent.click(screen.getByTestId(SOURCE_TARGET_TEST_ID)); + + expect(onUpdate).toHaveBeenCalledWith({ + session_target: { kind: "step", step_id: "step-1" }, + agent_profile_id: "", + }); + }); + + it("shows repair actions when a source step is no longer earlier", () => { + const source = { ...step, position: 2 } as WorkflowStep; + const destination = { + ...step, + id: "step-2", + name: "Review", + position: 1, + agent_profile_id: "", + session_target: { kind: "step", step_id: "step-1" }, + } as WorkflowStep; + const { onUpdate } = renderSelector(destination, false, [source, destination]); + + expect(screen.getByTestId("workflow-session-target-repair")).toBeTruthy(); + expect(screen.getByText("The source step must stay earlier than this step.")).toBeTruthy(); + + fireEvent.click(screen.getByTestId("workflow-session-target-clear")); + expect(onUpdate).toHaveBeenCalledWith({ session_target: null, agent_profile_id: "" }); + }); +}); diff --git a/apps/web/components/settings/workflow-step-agent-profile-selector.tsx b/apps/web/components/settings/workflow-step-agent-profile-selector.tsx index 4d08bca203..9149a88024 100644 --- a/apps/web/components/settings/workflow-step-agent-profile-selector.tsx +++ b/apps/web/components/settings/workflow-step-agent-profile-selector.tsx @@ -1,31 +1,20 @@ "use client"; -import { forwardRef, useEffect, useRef, useState, type ComponentPropsWithoutRef } from "react"; +import { + forwardRef, + useEffect, + useRef, + useState, + type ComponentPropsWithoutRef, + type ReactNode, + type RefObject, +} from "react"; import { useTranslation } from "react-i18next"; -import { IconChevronLeft, IconChevronRight, IconRobot, IconSelector } from "@tabler/icons-react"; +import { IconAlertTriangle, IconRobot, IconSelector } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@kandev/ui/command"; -import { - Drawer, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, -} from "@kandev/ui/drawer"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { AgentLogo } from "@/components/agent-logo"; -import type { - WorkflowProfileSessionEndPolicy, - WorkflowProfileSessionStartPolicy, - WorkflowStep, -} from "@/lib/types/http"; +import type { WorkflowSessionTarget, WorkflowStep } from "@/lib/types/http"; import { normalizeWorkflowProfileSessionEndPolicy, normalizeWorkflowProfileSessionStartPolicy, @@ -35,315 +24,130 @@ import { useHealthyAgentProfiles } from "@/hooks/domains/settings/use-healthy-ag import { settingsControlClassName } from "./settings-control"; import { HelpTip, hasOnEnterAction } from "./workflow-pipeline-editor-helpers"; import { isWorkflowStepValueDirty } from "./workflow-dirty-state"; - -type StartOption = { - value: WorkflowProfileSessionStartPolicy; - labelKey: string; - descriptionKey: string; - shortLabelKey: string; -}; - -type EndOption = { - value: WorkflowProfileSessionEndPolicy; - labelKey: string; - descriptionKey: string; - shortLabelKey: string; -}; - -const START_OPTIONS: StartOption[] = [ - { - value: "reuse", - labelKey: "workflows:profileSessionStartReuse", - descriptionKey: "workflows:profileSessionStartReuseDescription", - shortLabelKey: "workflows:profileSessionStartReuseShort", - }, - { - value: "new", - labelKey: "workflows:profileSessionStartNew", - descriptionKey: "workflows:profileSessionStartNewDescription", - shortLabelKey: "workflows:profileSessionStartNewShort", - }, -]; - -const END_OPTIONS: EndOption[] = [ - { - value: "complete", - labelKey: "workflows:profileSessionEndComplete", - descriptionKey: "workflows:profileSessionEndCompleteDescription", - shortLabelKey: "workflows:profileSessionEndCompleteShort", - }, - { - value: "park", - labelKey: "workflows:profileSessionEndPark", - descriptionKey: "workflows:profileSessionEndParkDescription", - shortLabelKey: "workflows:profileSessionEndParkShort", - }, -]; - -const PROFILE_SESSION_LIFECYCLE_KEY = "workflows:profileSessionLifecycle"; - -type SelectorView = "profiles" | "session"; +import { + MobileSelectorSurface, + SelectorSurface, + lifecycleSummary, + type SelectorView, +} from "./workflow-session-target-surfaces"; +import { + getWorkflowSessionTargetIssue, + type WorkflowSessionTargetIssue, +} from "./workflow-session-target-validation"; type WorkflowStepAgentProfileSelectorProps = { step: WorkflowStep; savedStep?: WorkflowStep; + steps?: WorkflowStep[]; onUpdate: (updates: Partial) => void; + onRestoreSource?: () => void; readOnly: boolean; }; -function startOption(value: unknown) { - const normalized = normalizeWorkflowProfileSessionStartPolicy(value); - return START_OPTIONS.find((option) => option.value === normalized) ?? START_OPTIONS[0]; +function targetStepFor(step: WorkflowStep, steps: WorkflowStep[]): WorkflowStep | undefined { + const target = step.session_target; + if (!target || target.kind !== "step") return undefined; + return steps.find((candidate) => candidate.id === target.step_id); } -function endOption(value: unknown) { - const normalized = normalizeWorkflowProfileSessionEndPolicy(value); - return END_OPTIONS.find((option) => option.value === normalized) ?? END_OPTIONS[0]; +function targetLabel( + step: WorkflowStep, + steps: WorkflowStep[], + profiles: ReturnType, + translate: (key: string, options?: Record) => string, +): string { + const target = step.session_target; + if (!target) return translate("workflows:noProfileOverride"); + if (target.kind === "initial") return translate("workflows:initialAgentSession"); + const source = targetStepFor(step, steps); + const profile = source + ? profiles.find((candidate) => candidate.id === source.agent_profile_id) + : undefined; + return translate("workflows:sessionTargetStepLabel", { + stepName: source?.name ?? translate("workflows:sessionTargetMissing"), + profileName: profile?.label ?? translate("workflows:profileUnavailable"), + }); } -function lifecycleSummary(step: WorkflowStep, translate: (key: string) => string): string { - return `${translate(startOption(step.profile_session_start_policy).shortLabelKey)} · ${translate(endOption(step.profile_session_end_policy).shortLabelKey)}`; +function sessionTargetIssueLabel( + issue: WorkflowSessionTargetIssue, + translate: (key: string) => string, +): string { + switch (issue.kind) { + case "missing-source": + return translate("workflows:sessionTargetSourceMissing"); + case "not-earlier": + return translate("workflows:sessionTargetSourceMustBeEarlier"); + case "missing-profile": + return translate("workflows:sessionTargetSourceNeedsProfile"); + case "indirect-source": + return translate("workflows:sessionTargetSourceMustBeDirect"); + } } -function ProfileOptionList({ - step, - profiles, +function SessionTargetRepair({ + issue, + canRestoreSource, readOnly, - onSelect, + onChooseAnother, + onClear, + onRestoreSource, }: { - step: WorkflowStep; - profiles: ReturnType; + issue: WorkflowSessionTargetIssue; + canRestoreSource: boolean; readOnly: boolean; - onSelect: (profileId: string) => void; + onChooseAnother: () => void; + onClear: () => void; + onRestoreSource?: () => void; }) { const { t } = useTranslation(); return ( - - - event.stopPropagation()} - > - {t("agents:profileNotFound")} - - onSelect("")} - className="min-h-11 cursor-pointer" - > - - {profiles.map((profile) => ( - onSelect(profile.id)} - className="min-h-11 cursor-pointer" - > - - {profile.label} - - ))} - - - - ); -} - -function LifecycleOptionList({ - step, - readOnly, - onStartSelect, - onEndSelect, -}: { - step: WorkflowStep; - readOnly: boolean; - onStartSelect: (policy: WorkflowProfileSessionStartPolicy) => void; - onEndSelect: (policy: WorkflowProfileSessionEndPolicy) => void; -}) { - const { t } = useTranslation(); - const selectedStart = normalizeWorkflowProfileSessionStartPolicy( - step.profile_session_start_policy, - ); - const selectedEnd = normalizeWorkflowProfileSessionEndPolicy(step.profile_session_end_policy); - - return ( -
-

- {t("workflows:profileSessionLifecycleIntro")} -

-
- - {t("workflows:profileSessionStartHeading")} - - {START_OPTIONS.map((option) => ( - - ))} -
-
- - {t("workflows:profileSessionEndHeading")} - - {END_OPTIONS.map((option) => ( - - ))} -
-
- ); -} - -function LifecycleNavigation({ - step, - readOnly, - onOpen, -}: { - step: WorkflowStep; - readOnly: boolean; - onOpen: () => void; -}) { - const { t } = useTranslation(); - return ( - - ); -} - -function SelectorSurface({ - step, - profiles, - readOnly, - view, - setView, - onUpdate, - onClose, - mobile, -}: { - step: WorkflowStep; - profiles: ReturnType; - readOnly: boolean; - view: SelectorView; - setView: (view: SelectorView) => void; - onUpdate: (updates: Partial) => void; - onClose: () => void; - mobile: boolean; -}) { - const { t } = useTranslation(); - const hasConditionalSessionConfig = hasOnEnterAction(step, "configure_session"); - const selectProfile = (profileId: string) => { - if (readOnly || hasConditionalSessionConfig) return; - onUpdate({ agent_profile_id: profileId }); - onClose(); - }; - - if (view === "session") { - return ( -
-
+

+
+ + + {canRestoreSource && onRestoreSource && ( -
- {mobile ? ( - {t(PROFILE_SESSION_LIFECYCLE_KEY)} - ) : ( -

{t(PROFILE_SESSION_LIFECYCLE_KEY)}

- )} -

- {t("workflows:profileSessionLifecycleIntro")} -

-
-
- - onUpdate({ profile_session_start_policy: profileSessionStartPolicy }) - } - onEndSelect={(profileSessionEndPolicy) => - onUpdate({ profile_session_end_policy: profileSessionEndPolicy }) - } - /> -
- ); - } - - return ( -
- {mobile ? ( - - {t("workflows:agentProfile")} - {t(PROFILE_SESSION_LIFECYCLE_KEY)} - - ) : null} - -
- setView("session")} /> + )}
); @@ -351,6 +155,7 @@ function SelectorSurface({ type SelectorTriggerProps = { selectedProfile: ReturnType[number] | undefined; + selectionLabel: string; summary: string; open: boolean; disabled: boolean; @@ -360,7 +165,17 @@ type SelectorTriggerProps = { const SelectorTrigger = forwardRef( function SelectorTrigger( - { selectedProfile, summary, open, disabled, dirty, onOpen, onClick, ...triggerProps }, + { + selectedProfile, + selectionLabel, + summary, + open, + disabled, + dirty, + onOpen, + onClick, + ...triggerProps + }, ref, ) { const { t } = useTranslation(); @@ -391,9 +206,7 @@ const SelectorTrigger = forwardRef(
{description && {description}} + {fixedContent}
{ await this.request("PUT", `/api/v1/workflow/steps/${stepId}`, { id: stepId, ...updates }); diff --git a/apps/web/e2e/tests/terminal/terminal-agent.spec.ts b/apps/web/e2e/tests/terminal/terminal-agent.spec.ts index 6ae1ed0329..14425928c8 100644 --- a/apps/web/e2e/tests/terminal/terminal-agent.spec.ts +++ b/apps/web/e2e/tests/terminal/terminal-agent.spec.ts @@ -232,8 +232,8 @@ test.describe("Terminal agent (TUI passthrough)", () => { await waitForSessionState(apiClient, { taskId: task.id, sessionId: task.session_id, - expectedState: "COMPLETED", - message: "the TUI source session did not complete after profile routing", + expectedState: "WAITING_FOR_INPUT", + message: "the TUI source session was not parked after profile routing", timeout: 60_000, }); diff --git a/apps/web/e2e/tests/workflow/mobile-workflow-session-targeting.spec.ts b/apps/web/e2e/tests/workflow/mobile-workflow-session-targeting.spec.ts new file mode 100644 index 0000000000..e3696a24c0 --- /dev/null +++ b/apps/web/e2e/tests/workflow/mobile-workflow-session-targeting.spec.ts @@ -0,0 +1,101 @@ +import { test, expect } from "../../fixtures/test-base"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { WorkflowSettingsPage } from "../../pages/workflow-settings-page"; +import { createWorkflowAgentProfiles } from "./workflow-agent-switch-helpers"; + +test.describe("mobile: workflow session targeting", () => { + test("keeps lifecycle controls fixed while choosing and saving a target", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + await testPage.setViewportSize({ width: 390, height: 844 }); + const { profileA } = await createWorkflowAgentProfiles(apiClient); + const { agents } = await apiClient.listAgents(); + const agentId = agents.find((agent) => agent.id !== "dynamic")?.id; + if (!agentId) test.fail(true, "the E2E fixture has no launchable agent family"); + for (let index = 0; index < 10; index++) { + await apiClient.createAgentProfile(agentId!, `Long list ${index}`, { model: "mock-fast" }); + } + + const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Mobile Session Targets"); + await apiClient.createWorkflowStep(workflow.id, "Plan", 0, { is_start_step: true }); + const implement = await apiClient.createWorkflowStep(workflow.id, "Implement", 1); + const review = await apiClient.createWorkflowStep(workflow.id, "Review", 2); + await apiClient.updateWorkflowStep(implement.id, { agent_profile_id: profileA.id }); + + const settings = new WorkflowSettingsPage(testPage); + await settings.goto(seedData.workspaceId); + const card = await settings.findWorkflowCard("Mobile Session Targets"); + await settings.selectStep(card, "Review", true); + + const selector = settings.stepAgentProfileSelect(card); + await selector.tap(); + const picker = testPage.getByTestId(`${review.id}-profile-picker-content`); + await expect(picker).toBeVisible(); + const lifecycle = testPage.getByTestId(`${review.id}-profile-session-lifecycle-select`); + await expect(lifecycle).toBeVisible(); + const lifecycleBox = await lifecycle.boundingBox(); + expect(lifecycleBox).not.toBeNull(); + expect(lifecycleBox!.height).toBeGreaterThanOrEqual(44); + expect(lifecycleBox!.x).toBeGreaterThanOrEqual(0); + expect(lifecycleBox!.x + lifecycleBox!.width).toBeLessThanOrEqual(390); + expect( + await lifecycle.evaluate( + (element, contentId) => element.closest(`[data-testid="${contentId}"]`) === null, + `${review.id}-profile-picker-content`, + ), + ).toBe(true); + + const search = picker.getByPlaceholder("Search agent profiles..."); + await search.fill("no mobile target"); + await expect(picker.getByText("Profile not found.", { exact: true })).toBeVisible(); + await expect(lifecycle).toBeVisible(); + await search.fill(""); + await expect( + picker.getByTestId(`${review.id}-session-target-step-${implement.id}`), + ).toBeVisible(); + + const initialTarget = picker.getByTestId(`${review.id}-session-target-initial`); + await initialTarget.tap(); + await expect(selector).toContainText("Initial agent session"); + + await selector.tap(); + await testPage.getByTestId(`${review.id}-profile-session-lifecycle-select`).tap(); + const lifecycleContent = testPage.getByTestId(`${review.id}-profile-session-lifecycle-content`); + const newOnStart = lifecycleContent.getByTestId(`${review.id}-profile-session-start-new`); + const parkOnEnd = lifecycleContent.getByTestId(`${review.id}-profile-session-end-park`); + for (const control of [newOnStart, parkOnEnd]) { + const box = await control.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height).toBeGreaterThanOrEqual(44); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(390); + } + await newOnStart.tap(); + await parkOnEnd.tap(); + await testPage.getByRole("button", { name: "Back", exact: true }).tap(); + await testPage.keyboard.press("Escape"); + await expect(selector).toBeFocused(); + + await settings.saveChanges(true); + const savedReview = (await apiClient.listWorkflowSteps(workflow.id)).steps.find( + (step) => step.id === review.id, + ); + expect(savedReview).toMatchObject({ + session_target: { kind: "initial" }, + profile_session_start_policy: "new", + profile_session_end_policy: "park", + }); + expect(savedReview?.agent_profile_id ?? "").toBe(""); + + await settings.goto(seedData.workspaceId); + const reloadedCard = await settings.findWorkflowCard("Mobile Session Targets"); + await settings.selectStep(reloadedCard, "Review", true); + await expect(settings.stepAgentProfileSelect(reloadedCard)).toContainText( + "Initial agent session", + ); + await assertNoDocumentHorizontalOverflow(testPage); + }); +}); diff --git a/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts b/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts index f23923841b..b39fd04689 100644 --- a/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts +++ b/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts @@ -2,6 +2,38 @@ import { test, expect } from "../../fixtures/test-base"; import { WorkflowSettingsPage } from "../../pages/workflow-settings-page"; test.describe("Workflow settings on mobile", () => { + test("keeps the workflow reorder handle inside the card with a touch-sized target", async ({ + testPage, + seedData, + }) => { + const page = new WorkflowSettingsPage(testPage); + await page.goto(seedData.workspaceId); + + const card = await page.findWorkflowCard("E2E Workflow"); + const handle = page.dragHandle(seedData.workflowId); + const list = testPage.getByTestId("workflow-order-list"); + await expect(handle).toBeVisible(); + await expect(handle).toHaveAccessibleName("Reorder E2E Workflow"); + + const [cardBox, handleBox, listBox] = await Promise.all([ + card.boundingBox(), + handle.boundingBox(), + list.boundingBox(), + ]); + expect(cardBox).not.toBeNull(); + expect(handleBox).not.toBeNull(); + expect(listBox).not.toBeNull(); + expect(handleBox!.width).toBeGreaterThanOrEqual(44); + expect(handleBox!.height).toBeGreaterThanOrEqual(44); + expect(handleBox!.x).toBeGreaterThanOrEqual(cardBox!.x); + expect(handleBox!.x + handleBox!.width).toBeLessThanOrEqual(cardBox!.x + cardBox!.width); + expect(handleBox!.x).toBeGreaterThan(cardBox!.x + cardBox!.width / 2); + expect(Math.abs(cardBox!.x - listBox!.x)).toBeLessThanOrEqual(1); + expect( + await testPage.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), + ).toBe(false); + }); + test("remove sync confirmation keeps 44px inline actions", async ({ testPage, apiClient, diff --git a/apps/web/e2e/tests/workflow/workflow-session-targeting.spec.ts b/apps/web/e2e/tests/workflow/workflow-session-targeting.spec.ts new file mode 100644 index 0000000000..31097ded69 --- /dev/null +++ b/apps/web/e2e/tests/workflow/workflow-session-targeting.spec.ts @@ -0,0 +1,298 @@ +import { test, expect } from "../../fixtures/test-base"; +import { WorkflowSettingsPage } from "../../pages/workflow-settings-page"; +import { + createWorkflowAgentProfiles, + waitForWorkflowProfileSession, +} from "./workflow-agent-switch-helpers"; + +async function waitForAgentMarker( + apiClient: InstanceType, + sessionId: string, + marker: string, +) { + await expect + .poll( + async () => { + const { messages } = await apiClient.listSessionMessages(sessionId); + return messages.some( + (message) => message.author_type === "agent" && message.content.includes(marker), + ); + }, + { timeout: 30_000, message: `session ${sessionId} did not receive ${marker}` }, + ) + .toBe(true); +} + +test.describe("Workflow session targeting", () => { + test("authors initial and source-session choices on desktop", async ({ + testPage, + apiClient, + seedData, + }) => { + const { profileA, profileB } = await createWorkflowAgentProfiles(apiClient); + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Desktop Session Targets", + ); + await apiClient.createWorkflowStep(workflow.id, "Plan", 0, { is_start_step: true }); + const implement = await apiClient.createWorkflowStep(workflow.id, "Implement", 1); + const review = await apiClient.createWorkflowStep(workflow.id, "Review", 2); + await apiClient.updateWorkflowStep(implement.id, { agent_profile_id: profileA.id }); + await apiClient.updateWorkflowStep(review.id, { agent_profile_id: profileB.id }); + + const settings = new WorkflowSettingsPage(testPage); + await settings.goto(seedData.workspaceId); + const card = await settings.findWorkflowCard("Desktop Session Targets"); + await settings.selectStep(card, "Review"); + + const selector = settings.stepAgentProfileSelect(card); + await selector.click(); + await expect(testPage.getByTestId(`${review.id}-session-target-initial`)).toBeVisible(); + await expect( + testPage.getByTestId(`${review.id}-session-target-step-${implement.id}`), + ).toBeVisible(); + + const lifecycle = testPage.locator( + `[data-testid="${review.id}-profile-session-lifecycle-select"]:visible`, + ); + await expect(lifecycle).toBeVisible(); + const search = testPage.getByPlaceholder("Search agent profiles..."); + await search.fill("does-not-exist"); + await expect(testPage.getByText("Profile not found.", { exact: true })).toBeVisible(); + await expect(lifecycle).toBeVisible(); + await search.fill(""); + + await testPage.getByTestId(`${review.id}-session-target-initial`).click(); + await expect(selector).toContainText("Initial agent session"); + await selector.click(); + await lifecycle.click(); + await testPage.getByTestId(`${review.id}-profile-session-start-new`).click(); + await testPage.getByTestId(`${review.id}-profile-session-end-park`).click(); + await testPage.keyboard.press("Escape"); + await settings.saveChanges(); + + const savedReview = (await apiClient.listWorkflowSteps(workflow.id)).steps.find( + (step) => step.id === review.id, + ); + expect(savedReview).toMatchObject({ + session_target: { kind: "initial" }, + profile_session_start_policy: "new", + profile_session_end_policy: "park", + }); + expect(savedReview?.agent_profile_id ?? "").toBe(""); + + await settings.goto(seedData.workspaceId); + const reloadedCard = await settings.findWorkflowCard("Desktop Session Targets"); + await settings.selectStep(reloadedCard, "Review"); + await expect(settings.stepAgentProfileSelect(reloadedCard)).toContainText( + "Initial agent session", + ); + }); + + test("delivers reuse and fresh initial targets to the intended conversations", async ({ + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const { profileA, profileB } = await createWorkflowAgentProfiles(apiClient); + const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Initial Target Runtime"); + const plan = await apiClient.createWorkflowStep(workflow.id, "Plan", 0); + const luna = await apiClient.createWorkflowStep(workflow.id, "Luna", 1); + const reviewReuse = await apiClient.createWorkflowStep(workflow.id, "Review reuse", 2); + const reviewFresh = await apiClient.createWorkflowStep(workflow.id, "Review fresh", 3); + + await apiClient.updateWorkflowStep(plan.id, { + agent_profile_id: profileA.id, + profile_session_end_policy: "park", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + await apiClient.updateWorkflowStep(luna.id, { + agent_profile_id: profileB.id, + profile_session_end_policy: "park", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + await apiClient.updateWorkflowStep(reviewReuse.id, { + session_target: { kind: "initial" }, + prompt: 'e2e:message("initial-target-reuse")', + profile_session_end_policy: "park", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + await apiClient.updateWorkflowStep(reviewFresh.id, { + session_target: { kind: "initial" }, + profile_session_start_policy: "new", + prompt: 'e2e:message("initial-target-fresh")', + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Initial target runtime", + profileA.id, + { + workflow_id: workflow.id, + workflow_step_id: plan.id, + repository_ids: [seedData.repositoryId], + }, + ); + const initialSessionId = await waitForWorkflowProfileSession(apiClient, task.id, profileA.id); + await apiClient.moveTask(task.id, workflow.id, luna.id); + const lunaSessionId = await waitForWorkflowProfileSession(apiClient, task.id, profileB.id); + + await apiClient.moveTask(task.id, workflow.id, reviewReuse.id); + await waitForAgentMarker(apiClient, initialSessionId, "initial-target-reuse"); + const afterReuse = (await apiClient.listTaskSessions(task.id)).sessions; + expect(afterReuse.filter((session) => session.agent_profile_id === profileA.id)).toHaveLength( + 1, + ); + expect(afterReuse.find((session) => session.id === initialSessionId)?.is_primary).toBe(true); + + await apiClient.moveTask(task.id, workflow.id, reviewFresh.id); + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(task.id); + return sessions.find( + (session) => + session.agent_profile_id === profileA.id && session.id !== initialSessionId, + )?.id; + }, + { timeout: 30_000, message: "fresh initial-target session was not created" }, + ) + .toBeDefined(); + const finalSessions = (await apiClient.listTaskSessions(task.id)).sessions; + const freshSession = finalSessions.find( + (session) => session.agent_profile_id === profileA.id && session.id !== initialSessionId, + ); + expect(freshSession).toBeDefined(); + await waitForAgentMarker(apiClient, freshSession!.id, "initial-target-fresh"); + expect(freshSession!.id).not.toBe(initialSessionId); + expect(finalSessions.find((session) => session.id === lunaSessionId)?.state).toBe( + "WAITING_FOR_INPUT", + ); + }); + + test("delivers a source-step target to its recorded conversation", async ({ + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const { agentId, profileA, profileB } = await createWorkflowAgentProfiles(apiClient); + const profileC = await apiClient.createAgentProfile(agentId, "Profile C (medium)", { + model: "mock-fast", + }); + const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Source Target Runtime"); + const plan = await apiClient.createWorkflowStep(workflow.id, "Plan", 0); + const implement = await apiClient.createWorkflowStep(workflow.id, "Implement", 1); + const verify = await apiClient.createWorkflowStep(workflow.id, "Verify", 2); + const review = await apiClient.createWorkflowStep(workflow.id, "Review", 3); + + await apiClient.updateWorkflowStep(plan.id, { + agent_profile_id: profileA.id, + profile_session_end_policy: "park", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + await apiClient.updateWorkflowStep(implement.id, { + agent_profile_id: profileB.id, + profile_session_end_policy: "park", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + await apiClient.updateWorkflowStep(verify.id, { + agent_profile_id: profileC.id, + profile_session_end_policy: "park", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + await apiClient.updateWorkflowStep(review.id, { + session_target: { kind: "step", step_id: implement.id }, + prompt: 'e2e:message("source-target-reuse")', + profile_session_end_policy: "park", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Source target runtime", + profileA.id, + { + workflow_id: workflow.id, + workflow_step_id: plan.id, + repository_ids: [seedData.repositoryId], + }, + ); + await waitForWorkflowProfileSession(apiClient, task.id, profileA.id); + await apiClient.moveTask(task.id, workflow.id, implement.id); + const implementSessionId = await waitForWorkflowProfileSession(apiClient, task.id, profileB.id); + await apiClient.moveTask(task.id, workflow.id, verify.id); + await waitForWorkflowProfileSession(apiClient, task.id, profileC.id); + + await apiClient.moveTask(task.id, workflow.id, review.id); + await waitForAgentMarker(apiClient, implementSessionId, "source-target-reuse"); + + const finalSessions = (await apiClient.listTaskSessions(task.id)).sessions; + expect( + finalSessions.filter((session) => session.agent_profile_id === profileB.id), + ).toHaveLength(1); + expect(finalSessions.find((session) => session.id === implementSessionId)?.is_primary).toBe( + true, + ); + expect(finalSessions.find((session) => session.agent_profile_id === profileC.id)?.state).toBe( + "WAITING_FOR_INPUT", + ); + }); + + test("surfaces a repair state when a source step is moved after its dependent", async ({ + testPage, + apiClient, + seedData, + }) => { + const { profileA } = await createWorkflowAgentProfiles(apiClient); + const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Repair Session Target"); + const source = await apiClient.createWorkflowStep(workflow.id, "Implement", 0); + const review = await apiClient.createWorkflowStep(workflow.id, "Review", 1); + await apiClient.updateWorkflowStep(source.id, { agent_profile_id: profileA.id }); + await apiClient.updateWorkflowStep(review.id, { + session_target: { kind: "step", step_id: source.id }, + }); + + const settings = new WorkflowSettingsPage(testPage); + await settings.goto(seedData.workspaceId); + const card = await settings.findWorkflowCard("Repair Session Target"); + await settings.selectStep(card, "Review"); + + const sourceNode = settings.stepNodeByName(card, "Implement"); + const reviewNode = settings.stepNodeByName(card, "Review"); + const sourceHandle = sourceNode.locator("button").first(); + const sourceBox = await sourceHandle.boundingBox(); + const reviewBox = await reviewNode.boundingBox(); + expect(sourceBox).not.toBeNull(); + expect(reviewBox).not.toBeNull(); + await testPage.mouse.move( + sourceBox!.x + sourceBox!.width / 2, + sourceBox!.y + sourceBox!.height / 2, + ); + await testPage.mouse.down(); + await testPage.mouse.move( + reviewBox!.x + reviewBox!.width / 2, + reviewBox!.y + reviewBox!.height / 2, + { + steps: 8, + }, + ); + await testPage.mouse.up(); + + await expect(settings.stepNodeByName(card, "Review")).toHaveAttribute( + "data-workflow-invalid-session-target", + "true", + ); + await expect(testPage.getByTestId("workflow-session-target-repair")).toBeVisible(); + await expect( + settings.floatingSave.getByRole("button", { name: "Save changes" }), + ).toBeDisabled(); + await testPage.getByTestId("workflow-session-target-clear").click(); + await settings.saveChanges(); + + const savedReview = (await apiClient.listWorkflowSteps(workflow.id)).steps.find( + (step) => step.id === review.id, + ); + expect(savedReview?.session_target ?? null).toBeNull(); + }); +}); diff --git a/apps/web/e2e/tests/workflow/workflow-sorting.spec.ts b/apps/web/e2e/tests/workflow/workflow-sorting.spec.ts index 71f7e7d41a..b06b6c0742 100644 --- a/apps/web/e2e/tests/workflow/workflow-sorting.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-sorting.spec.ts @@ -58,7 +58,7 @@ test.describe("Workflow sorting", () => { ]); }); - test("settings page shows drag handles for workflows", async ({ + test("settings page places workflow drag handles inside cards without a list gutter", async ({ testPage, apiClient, seedData, @@ -71,10 +71,26 @@ test.describe("Workflow sorting", () => { const page = new WorkflowSettingsPage(testPage); await page.goto(workspaceId); - // Verify drag handles are visible + const card = await page.findWorkflowCard("E2E Workflow"); + const handle = page.dragHandle(seedData.workflowId); + const list = testPage.getByTestId("workflow-order-list"); const handles = testPage.locator('[data-testid^="workflow-drag-handle-"]'); - await expect(handles.first()).toBeVisible(); + await expect(handle).toBeVisible(); + await expect(handle).toHaveAccessibleName("Reorder E2E Workflow"); expect(await handles.count()).toBeGreaterThanOrEqual(2); + + const [cardBox, handleBox, listBox] = await Promise.all([ + card.boundingBox(), + handle.boundingBox(), + list.boundingBox(), + ]); + expect(cardBox).not.toBeNull(); + expect(handleBox).not.toBeNull(); + expect(listBox).not.toBeNull(); + expect(handleBox!.x).toBeGreaterThanOrEqual(cardBox!.x); + expect(handleBox!.x + handleBox!.width).toBeLessThanOrEqual(cardBox!.x + cardBox!.width); + expect(handleBox!.x).toBeGreaterThan(cardBox!.x + cardBox!.width / 2); + expect(Math.abs(cardBox!.x - listBox!.x)).toBeLessThanOrEqual(1); }); test("kanban board respects workflow sort order", async ({ testPage, apiClient, seedData }) => { diff --git a/apps/web/e2e/tests/workflow/workflow-step-autocomplete.spec.ts b/apps/web/e2e/tests/workflow/workflow-step-autocomplete.spec.ts index e62010ef54..9f98d97254 100644 --- a/apps/web/e2e/tests/workflow/workflow-step-autocomplete.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-step-autocomplete.spec.ts @@ -137,6 +137,8 @@ test.describe("Workflow step prompt autocomplete", () => { }) => { const page = new WorkflowSettingsPage(testPage); await page.goto(seedData.workspaceId); + const stepId = seedData.steps[0]?.id; + expect(stepId).toBeTruthy(); const card = await page.findWorkflowCard("E2E Workflow"); await expect(card).toBeVisible(); @@ -156,7 +158,8 @@ test.describe("Workflow step prompt autocomplete", () => { // Click to open the dropdown await agentSelect.click(); - // Select the first non-"none" option (skip "No profile override"). + // Select the first actual profile option. Session-target choices can appear + // before the profile group, so role order does not identify a profile. // `count()` is a one-shot read, not an auto-retrying assertion, so gate on // the listbox being populated before counting instead of sleeping first. // @@ -164,22 +167,24 @@ test.describe("Workflow step prompt autocomplete", () => { // is static markup and is already there while the settings bootstrap is // still loading profiles, so a count() taken on it reads 1 and skips the // test. Poll the count itself, which is the thing the branch below reads. - const options = testPage.getByRole("option"); - await expect(options.first()).toBeVisible({ timeout: 5_000 }); + const noProfileOption = testPage.getByTestId(`${stepId}-profile-option-none`); + const profileOptions = testPage.locator( + `[data-testid^="${stepId}-profile-option-"]:not([data-testid="${stepId}-profile-option-none"])`, + ); + await expect(noProfileOption).toBeVisible({ timeout: 5_000 }); await expect - .poll(() => options.count(), { timeout: 5_000 }) - .toBeGreaterThan(1) + .poll(() => profileOptions.count(), { timeout: 5_000 }) + .toBeGreaterThan(0) // A workspace with genuinely no profiles is a legitimate skip, so this // stays tolerant; the poll only removes the race with a slow bootstrap. .catch(() => undefined); - const optionCount = await options.count(); - // Need at least 2 options (none + at least one profile) - if (optionCount < 2) { + const optionCount = await profileOptions.count(); + if (optionCount < 1) { test.skip(true, "No agent profiles available to test with"); return; } - const profileOption = options.nth(1); + const profileOption = profileOptions.first(); const profileName = await profileOption.textContent(); await profileOption.click(); @@ -204,7 +209,6 @@ test.describe("Workflow step prompt autocomplete", () => { await expect(reloadedSelect).toContainText(profileName?.trim() ?? "", { timeout: 10_000 }); // Clean up: reset the step agent profile - const stepId = seedData.steps[0]?.id; if (stepId) { await apiClient.updateWorkflowStep(stepId, { agent_profile_id: "" }); } diff --git a/apps/web/lib/api/domains/workflow-api.test.ts b/apps/web/lib/api/domains/workflow-api.test.ts index 8e5a350225..c8b8248d60 100644 --- a/apps/web/lib/api/domains/workflow-api.test.ts +++ b/apps/web/lib/api/domains/workflow-api.test.ts @@ -104,7 +104,7 @@ describe("createWorkflowStep", () => { ); expect(step.profile_session_start_policy).toBe("reuse"); - expect(step.profile_session_end_policy).toBe("complete"); + expect(step.profile_session_end_policy).toBe("park"); expect(step.complete_task_on_enter).toBe(false); }); }); diff --git a/apps/web/lib/api/domains/workflow-api.ts b/apps/web/lib/api/domains/workflow-api.ts index 6964ef5e7e..728269c1e8 100644 --- a/apps/web/lib/api/domains/workflow-api.ts +++ b/apps/web/lib/api/domains/workflow-api.ts @@ -24,6 +24,7 @@ type BackendTemplateStep = { is_start_step?: boolean; show_in_command_panel?: boolean; agent_profile_id?: string; + session_target?: StepDefinition["session_target"]; profile_session_start_policy?: StepDefinition["profile_session_start_policy"]; profile_session_end_policy?: StepDefinition["profile_session_end_policy"]; complete_task_on_enter?: boolean; @@ -50,6 +51,7 @@ export const normalizeWorkflowTemplate = (template: BackendWorkflowTemplate): Wo is_start_step: step.is_start_step, show_in_command_panel: step.show_in_command_panel, agent_profile_id: step.agent_profile_id ? agentProfileId(step.agent_profile_id) : undefined, + session_target: step.session_target ?? null, profile_session_start_policy: normalizeWorkflowProfileSessionStartPolicy( step.profile_session_start_policy, ), @@ -135,6 +137,7 @@ export async function createWorkflowStep( auto_advance_requires_signal?: boolean; cancel_triggers_turn_complete?: boolean; agent_profile_id?: string; + session_target?: StepDefinition["session_target"]; profile_session_start_policy?: StepDefinition["profile_session_start_policy"]; profile_session_end_policy?: StepDefinition["profile_session_end_policy"]; wip_limit?: number; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index c7509cb8c0..604f3476bc 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -129,6 +129,7 @@ export type StepDefinition = { agent_profile_id?: AgentProfileId; profile_session_start_policy?: WorkflowProfileSessionStartPolicy; profile_session_end_policy?: WorkflowProfileSessionEndPolicy; + session_target?: WorkflowSessionTarget | null; execution_profile_id?: AgentProfileId; route_generation?: number; route_state?: string; @@ -157,6 +158,7 @@ export type WorkflowStep = { agent_profile_id?: string; profile_session_start_policy?: WorkflowProfileSessionStartPolicy; profile_session_end_policy?: WorkflowProfileSessionEndPolicy; + session_target?: WorkflowSessionTarget | null; complete_task_on_enter?: boolean; wip_limit?: number; pull_from_step_id?: string | null; @@ -230,6 +232,7 @@ export type TaskPendingActionRevision = { export type WorkflowProfileSessionStartPolicy = "reuse" | "new"; export type WorkflowProfileSessionEndPolicy = "complete" | "park"; +export type WorkflowSessionTarget = { kind: "initial" } | { kind: "step"; step_id: string }; export function normalizeWorkflowProfileSessionStartPolicy( value: unknown, @@ -240,7 +243,7 @@ export function normalizeWorkflowProfileSessionStartPolicy( export function normalizeWorkflowProfileSessionEndPolicy( value: unknown, ): WorkflowProfileSessionEndPolicy { - return typeof value === "string" && value.trim() === "park" ? "park" : "complete"; + return typeof value === "string" && value.trim() === "complete" ? "complete" : "park"; } /** @@ -526,8 +529,8 @@ export type WorkflowStepDTO = { agent_profile_id?: AgentProfileId; profile_session_start_policy?: WorkflowProfileSessionStartPolicy; profile_session_end_policy?: WorkflowProfileSessionEndPolicy; + session_target?: WorkflowSessionTarget | null; stage_type?: "work" | "review" | "approval" | "custom"; - auto_advance_requires_signal?: boolean; wip_limit?: number; pull_from_step_id?: string | null; complete_task_on_enter: boolean; @@ -990,6 +993,7 @@ export type StepPortable = { agent_profile?: AgentProfilePortable; profile_session_start_policy?: WorkflowProfileSessionStartPolicy; profile_session_end_policy?: WorkflowProfileSessionEndPolicy; + session_target?: { kind: "initial" } | { kind: "step"; step_position: number } | null; complete_task_on_enter: boolean; auto_advance_requires_signal: boolean; cancel_triggers_turn_complete: boolean; diff --git a/apps/web/lib/ws/handlers/workflows.ts b/apps/web/lib/ws/handlers/workflows.ts index 54462dd46d..00ef9bfeed 100644 --- a/apps/web/lib/ws/handlers/workflows.ts +++ b/apps/web/lib/ws/handlers/workflows.ts @@ -20,10 +20,10 @@ function stepFromPayload(step: any) { events: step.events, show_in_command_panel: step.show_in_command_panel, allow_manual_move: step.allow_manual_move, - auto_advance_requires_signal: step.auto_advance_requires_signal, prompt: step.prompt, is_start_step: step.is_start_step, agent_profile_id: step.agent_profile_id, + session_target: step.session_target ?? null, profile_session_start_policy: normalizeWorkflowProfileSessionStartPolicy( step.profile_session_start_policy, ), diff --git a/apps/web/src/locales/en/workflows.json b/apps/web/src/locales/en/workflows.json index 3bffe453fd..1e4ed8ce7b 100644 --- a/apps/web/src/locales/en/workflows.json +++ b/apps/web/src/locales/en/workflows.json @@ -117,6 +117,25 @@ "noFeederStep": "No feeder step", "noneUseTaskDefault": "None (use task default)", "noProfileOverride": "No profile override", + "agentProfiles": "Agent profiles", + "workflowSessions": "Workflow sessions", + "initialAgentSession": "Initial agent session", + "initialAgentSessionDescription": "Use the task's original conversation.", + "sessionTargetStepLabel": "{{stepName}} · {{profileName}}", + "sessionTargetMissing": "Missing source step", + "profileUnavailable": "Profile unavailable", + "sessionTargetAndProfileDescription": "Choose a workflow conversation or an agent profile.", + "sessionTargetRepairTitle": "Repair this session target", + "sessionTargetSourceMissing": "The source step no longer exists.", + "sessionTargetSourceMustBeEarlier": "The source step must stay earlier than this step.", + "sessionTargetSourceNeedsProfile": "The source step needs a direct agent profile.", + "sessionTargetSourceMustBeDirect": "The source step cannot target another session.", + "sessionTargetChooseAnother": "Choose another target", + "sessionTargetUseNoProfile": "Use no profile override", + "sessionTargetUndoSourceEdit": "Undo the source edit", + "workflowSessionTargetMustBeRepaired": "Repair invalid session targets before saving.", + "sessionTargetDependentMustBeRepaired": "Repair dependent session targets first", + "sessionTargetDependentRepairDescription": "{{stepName}} uses this step as its session source. Repair that target before deleting this step.", "notARecognizedRepositoryLink": "Not a recognized {{provider}} repository link.", "notSyncedYet": "not synced yet; use Sync now", "onExit": "On Exit", @@ -272,6 +291,7 @@ "workflowDescription": "Description", "workflowDescriptionPlaceholder": "Optional description", "workflowDetails": "Workflow details", + "reorderWorkflow": "Reorder {{name}}", "workflowName": "Workflow Name", "workflowsReadOnlyImprove": "Workflows in this workspace are managed by Improve Kandev and cannot be changed.", "workflowNameIsRequired": "Workflow name is required", diff --git a/apps/web/src/locales/pseudo/workflows.json b/apps/web/src/locales/pseudo/workflows.json index b25f1a4b58..26e2ccee7b 100644 --- a/apps/web/src/locales/pseudo/workflows.json +++ b/apps/web/src/locales/pseudo/workflows.json @@ -117,6 +117,25 @@ "noFeederStep": "Ńō ƒēēďēŕ śţēƥ", "noneUseTaskDefault": "Ńōńē (ũśē ţàśķ ďēƒàũĺţ)", "noProfileOverride": "Ńō ƥŕōƒĩĺē ōvēŕŕĩďē", + "agentProfiles": "Àĝēńţ ƥŕōƒĩĺēś", + "workflowSessions": "Ŵōŕķƒĺōŵ śēśśĩōńś", + "initialAgentSession": "Ĩńĩţĩàĺ àĝēńţ śēśśĩōń", + "initialAgentSessionDescription": "Ũśē ţĥē ţàśķ'ś ōŕĩĝĩńàĺ ćōńvēŕśàţĩōń.", + "sessionTargetStepLabel": "{{stepName}} · {{profileName}}", + "sessionTargetMissing": "Ḿĩśśĩńĝ śōũŕćē śţēƥ", + "profileUnavailable": "Ƥŕōƒĩĺē ũńàvàĩĺàƀĺē", + "sessionTargetAndProfileDescription": "Ćĥōōśē à ŵōŕķƒĺōŵ ćōńvēŕśàţĩōń ōŕ àń àĝēńţ ƥŕōƒĩĺē.", + "sessionTargetRepairTitle": "Ŕēƥàĩŕ ţĥĩś śēśśĩōń ţàŕĝēţ", + "sessionTargetSourceMissing": "Ţĥē śōũŕćē śţēƥ ńō ĺōńĝēŕ ēxĩśţś.", + "sessionTargetSourceMustBeEarlier": "Ţĥē śōũŕćē śţēƥ ḿũśţ śţàŷ ēàŕĺĩēŕ ţĥàń ţĥĩś śţēƥ.", + "sessionTargetSourceNeedsProfile": "Ţĥē śōũŕćē śţēƥ ńēēďś à ďĩŕēćţ àĝēńţ ƥŕōƒĩĺē.", + "sessionTargetSourceMustBeDirect": "Ţĥē śōũŕćē śţēƥ ćàńńōţ ţàŕĝēţ àńōţĥēŕ śēśśĩōń.", + "sessionTargetChooseAnother": "Ćĥōōśē àńōţĥēŕ ţàŕĝēţ", + "sessionTargetUseNoProfile": "Ũśē ńō ƥŕōƒĩĺē ōvēŕŕĩďē", + "sessionTargetUndoSourceEdit": "Ũńďō ţĥē śōũŕćē ēďĩţ", + "workflowSessionTargetMustBeRepaired": "Ŕēƥàĩŕ ĩńvàĺĩď śēśśĩōń ţàŕĝēţś ƀēƒōŕē śàvĩńĝ.", + "sessionTargetDependentMustBeRepaired": "Ŕēƥàĩŕ ďēƥēńďēńţ śēśśĩōń ţàŕĝēţś ƒĩŕśţ", + "sessionTargetDependentRepairDescription": "{{stepName}} ũśēś ţĥĩś śţēƥ àś ĩţś śēśśĩōń śōũŕćē. Ŕēƥàĩŕ ţĥàţ ţàŕĝēţ ƀēƒōŕē ďēĺēţĩńĝ ţĥĩś śţēƥ.", "notARecognizedRepositoryLink": "Ńōţ à ŕēćōĝńĩźēď {{provider}} ŕēƥōśĩţōŕŷ ĺĩńķ.", "notSyncedYet": "ńōţ śŷńćēď ŷēţ; ũśē Śŷńć ńōŵ", "onExit": "Ōń Ēxĩţ", @@ -272,6 +291,7 @@ "workflowDescription": "Ďēśćŕĩƥţĩōń", "workflowDescriptionPlaceholder": "Ōƥţĩōńàĺ ďēśćŕĩƥţĩōń", "workflowDetails": "Ŵōŕķƒĺōŵ ďēţàĩĺś", + "reorderWorkflow": "Ŕēōŕďēŕ {{name}}", "workflowName": "Ŵōŕķƒĺōŵ Ńàḿē", "workflowsReadOnlyImprove": "Ŵōŕķƒĺōŵś ĩń ţĥĩś ŵōŕķśƥàćē àŕē ḿàńàĝēď ƀŷ Ĩḿƥŕōvē Ķàńďēv àńď ćàńńōţ ƀē ćĥàńĝēď.", "workflowNameIsRequired": "Ŵōŕķƒĺōŵ ńàḿē ĩś ŕēqũĩŕēď", diff --git a/apps/web/src/locales/pt-pt/workflows.json b/apps/web/src/locales/pt-pt/workflows.json index 82a1390e69..f59fa87cf2 100644 --- a/apps/web/src/locales/pt-pt/workflows.json +++ b/apps/web/src/locales/pt-pt/workflows.json @@ -113,6 +113,25 @@ "noFeederStep": "Sem passo alimentador", "noneUseTaskDefault": "Nenhum (usar a pré-definição da tarefa)", "noProfileOverride": "Sem substituição de perfil", + "agentProfiles": "Perfis de agente", + "workflowSessions": "Sessões do fluxo de trabalho", + "initialAgentSession": "Sessão do agente inicial", + "initialAgentSessionDescription": "Usar a conversa original da tarefa.", + "sessionTargetStepLabel": "{{stepName}} · {{profileName}}", + "sessionTargetMissing": "Passo de origem em falta", + "profileUnavailable": "Perfil indisponível", + "sessionTargetAndProfileDescription": "Escolha uma conversa do fluxo de trabalho ou um perfil de agente.", + "sessionTargetRepairTitle": "Reparar esta sessão de destino", + "sessionTargetSourceMissing": "O passo de origem já não existe.", + "sessionTargetSourceMustBeEarlier": "O passo de origem tem de permanecer antes deste passo.", + "sessionTargetSourceNeedsProfile": "O passo de origem precisa de um perfil de agente direto.", + "sessionTargetSourceMustBeDirect": "O passo de origem não pode apontar para outra sessão.", + "sessionTargetChooseAnother": "Escolher outro destino", + "sessionTargetUseNoProfile": "Usar sem substituição de perfil", + "sessionTargetUndoSourceEdit": "Desfazer a edição da origem", + "workflowSessionTargetMustBeRepaired": "Repare as sessões de destino inválidas antes de guardar.", + "sessionTargetDependentMustBeRepaired": "Repare primeiro os destinos de sessão dependentes", + "sessionTargetDependentRepairDescription": "{{stepName}} usa este passo como origem da sessão. Repare esse destino antes de eliminar este passo.", "notARecognizedRepositoryLink": "Não é uma ligação reconhecida de um repositório do {{provider}}.", "notSyncedYet": "ainda não sincronizado; use Sincronizar agora", "onExit": "À saída", @@ -267,6 +286,7 @@ "workflowCycleReplayPathFor": "Percurso de repetição de {{stepName}}", "workflowCycleWarningBody": "Este percurso pode voltar a entrar em \"{{stepName}}\" e iniciar novamente o agente após uma ação do utilizador.", "workflowDetails": "Detalhes do fluxo de trabalho", + "reorderWorkflow": "Reordenar {{name}}", "workflowName": "Nome do fluxo de trabalho", "workflowsReadOnlyImprove": "Os fluxos de trabalho desta área de trabalho são geridos pelo Melhorar o Kandev e não podem ser alterados.", "workflowNameIsRequired": "O nome do fluxo de trabalho é obrigatório", diff --git a/apps/web/src/locales/zh-cn/workflows.json b/apps/web/src/locales/zh-cn/workflows.json index 7e19d22107..baf87a36e3 100644 --- a/apps/web/src/locales/zh-cn/workflows.json +++ b/apps/web/src/locales/zh-cn/workflows.json @@ -117,6 +117,25 @@ "noFeederStep": "无馈送步骤", "noneUseTaskDefault": "无(使用任务默认)", "noProfileOverride": "无配置覆盖", + "agentProfiles": "智能体配置", + "workflowSessions": "工作流会话", + "initialAgentSession": "初始智能体会话", + "initialAgentSessionDescription": "使用任务的原始对话。", + "sessionTargetStepLabel": "{{stepName}} · {{profileName}}", + "sessionTargetMissing": "缺少源步骤", + "profileUnavailable": "配置不可用", + "sessionTargetAndProfileDescription": "选择工作流对话或智能体配置。", + "sessionTargetRepairTitle": "修复此会话目标", + "sessionTargetSourceMissing": "源步骤已不存在。", + "sessionTargetSourceMustBeEarlier": "源步骤必须保持在此步骤之前。", + "sessionTargetSourceNeedsProfile": "源步骤需要直接的智能体配置。", + "sessionTargetSourceMustBeDirect": "源步骤不能指向其他会话。", + "sessionTargetChooseAnother": "选择其他目标", + "sessionTargetUseNoProfile": "不使用配置覆盖", + "sessionTargetUndoSourceEdit": "撤销源步骤编辑", + "workflowSessionTargetMustBeRepaired": "请先修复无效的会话目标,再保存。", + "sessionTargetDependentMustBeRepaired": "请先修复依赖的会话目标", + "sessionTargetDependentRepairDescription": "{{stepName}} 使用此步骤作为会话源。请先修复该目标,再删除此步骤。", "notARecognizedRepositoryLink": "无法识别的 {{provider}} 仓库链接。", "notSyncedYet": "尚未同步;请使用“立即同步”", "onExit": "退出时", @@ -272,6 +291,7 @@ "workflowDescription": "描述", "workflowDescriptionPlaceholder": "可选描述", "workflowDetails": "工作流详情", + "reorderWorkflow": "重新排序“{{name}}”", "workflowName": "工作流名称", "workflowsReadOnlyImprove": "此工作区中的工作流由 Improve Kandev 管理,无法更改。", "workflowNameIsRequired": "工作流名称为必填项", diff --git a/apps/web/src/locales/zh-hk/workflows.json b/apps/web/src/locales/zh-hk/workflows.json index fafa48e27b..cf0a7d1165 100644 --- a/apps/web/src/locales/zh-hk/workflows.json +++ b/apps/web/src/locales/zh-hk/workflows.json @@ -117,6 +117,25 @@ "noFeederStep": "無饋送步驟", "noneUseTaskDefault": "無(使用任務預設)", "noProfileOverride": "無設定覆蓋", + "agentProfiles": "代理程式設定檔", + "workflowSessions": "工作流程工作階段", + "initialAgentSession": "初始代理程式工作階段", + "initialAgentSessionDescription": "使用任務的原始對話。", + "sessionTargetStepLabel": "{{stepName}} · {{profileName}}", + "sessionTargetMissing": "缺少源步驟", + "profileUnavailable": "設定不可用", + "sessionTargetAndProfileDescription": "選擇工作流程對話或代理程式設定檔。", + "sessionTargetRepairTitle": "修復此工作階段目標", + "sessionTargetSourceMissing": "源步驟已不存在。", + "sessionTargetSourceMustBeEarlier": "源步驟必須保持在此步驟之前。", + "sessionTargetSourceNeedsProfile": "源步驟需要直接的代理程式設定檔。", + "sessionTargetSourceMustBeDirect": "源步驟不能指向其他工作階段。", + "sessionTargetChooseAnother": "選擇其他目標", + "sessionTargetUseNoProfile": "不使用設定覆蓋", + "sessionTargetUndoSourceEdit": "撤銷源步驟編輯", + "workflowSessionTargetMustBeRepaired": "請先修復無效的工作階段目標,再儲存。", + "sessionTargetDependentMustBeRepaired": "請先修復依賴的工作階段目標", + "sessionTargetDependentRepairDescription": "{{stepName}} 使用此步驟作為工作階段源。請先修復該目標,再刪除此步驟。", "notARecognizedRepositoryLink": "無法識別的 {{provider}} 儲存庫連結。", "notSyncedYet": "尚未同步;請使用「立即同步」", "onExit": "退出時", @@ -272,6 +291,7 @@ "workflowDescription": "描述", "workflowDescriptionPlaceholder": "可選描述", "workflowDetails": "工作流程詳情", + "reorderWorkflow": "重新排序「{{name}}」", "workflowName": "工作流程名稱", "workflowsReadOnlyImprove": "此工作區中的工作流程由 Improve Kandev 管理,無法更改。", "workflowNameIsRequired": "工作流程名稱為必填項", diff --git a/apps/web/src/locales/zh-tw/workflows.json b/apps/web/src/locales/zh-tw/workflows.json index bb1fb47723..3a817095fa 100644 --- a/apps/web/src/locales/zh-tw/workflows.json +++ b/apps/web/src/locales/zh-tw/workflows.json @@ -117,6 +117,25 @@ "noFeederStep": "無饋送步驟", "noneUseTaskDefault": "無(使用任務預設)", "noProfileOverride": "無設定覆蓋", + "agentProfiles": "代理程式設定檔", + "workflowSessions": "工作流程工作階段", + "initialAgentSession": "初始代理程式工作階段", + "initialAgentSessionDescription": "使用任務的原始對話。", + "sessionTargetStepLabel": "{{stepName}} · {{profileName}}", + "sessionTargetMissing": "缺少源步驟", + "profileUnavailable": "設定不可用", + "sessionTargetAndProfileDescription": "選擇工作流程對話或代理程式設定檔。", + "sessionTargetRepairTitle": "修復此工作階段目標", + "sessionTargetSourceMissing": "源步驟已不存在。", + "sessionTargetSourceMustBeEarlier": "源步驟必須保持在此步驟之前。", + "sessionTargetSourceNeedsProfile": "源步驟需要直接的代理程式設定檔。", + "sessionTargetSourceMustBeDirect": "源步驟不能指向其他工作階段。", + "sessionTargetChooseAnother": "選擇其他目標", + "sessionTargetUseNoProfile": "不使用設定覆蓋", + "sessionTargetUndoSourceEdit": "撤銷源步驟編輯", + "workflowSessionTargetMustBeRepaired": "請先修復無效的工作階段目標,再儲存。", + "sessionTargetDependentMustBeRepaired": "請先修復依賴的工作階段目標", + "sessionTargetDependentRepairDescription": "{{stepName}} 使用此步驟作為工作階段源。請先修復該目標,再刪除此步驟。", "notARecognizedRepositoryLink": "無法識別的 {{provider}} 儲存庫連結。", "notSyncedYet": "尚未同步;請使用「立即同步」", "onExit": "退出時", @@ -272,6 +291,7 @@ "workflowDescription": "描述", "workflowDescriptionPlaceholder": "可選描述", "workflowDetails": "工作流程詳情", + "reorderWorkflow": "重新排序「{{name}}」", "workflowName": "工作流程名稱", "workflowsReadOnlyImprove": "此工作區中的工作流程由 Improve Kandev 管理,無法更改。", "workflowNameIsRequired": "工作流程名稱為必填項", diff --git a/docs/decisions/2026-08-31-workflow-profile-session-switch-policy.md b/docs/decisions/2026-08-31-workflow-profile-session-switch-policy.md index 6a008ae4e1..74215663a5 100644 --- a/docs/decisions/2026-08-31-workflow-profile-session-switch-policy.md +++ b/docs/decisions/2026-08-31-workflow-profile-session-switch-policy.md @@ -32,8 +32,10 @@ The defaults are `reuse` and `complete`. These defaults preserve existing behavior. The router reuses an eligible nonterminal destination session when one exists. It completes the source session. -The settings apply only when the effective agent profile changes. Consecutive -steps with the same effective profile keep the active session. +For profile-only routing, the settings apply only when the effective agent +profile changes. Consecutive steps with the same effective profile keep the +active session. The proposed explicit-target extension below distinguishes +session identity from profile identity. The workflow engine keeps its transition graph, actions, events, and states. The transition integration must provide both source and destination settings to the @@ -75,3 +77,31 @@ new-task selector. files, logs, and tests. - Add a `PARKED` task-session state: rejected because `WAITING_FOR_INPUT` already represents a stopped, resumable session. + +## Proposed extension: explicit session targets (2026-09-09) + +The [recipient design](../specs/tasks/system-design/workflow-profile-session-lifecycle.md#explicit-recipient-contract) +adds initial-session and earlier-step references beside legacy profile routing. +This extension is pending implementation through the +[session-targeting plan](../plans/workflow-session-targeting/plan.md). + +An explicit target resolves a conversation identity before applying the start +setting. Its `new` setting must work even when profiles match. The source step +still owns retirement, and terminal sessions remain unavailable for reuse. + +An initial reference retains the original identity. A source-step reference +uses that step's latest successful session selection. Deliver initial targeting +first, using the existing original marker and a write-once task metadata +snapshot that survives session deletion. A task metadata route record makes +fresh selection retry-safe from the first slice. Add durable source-step +bindings in the second slice; initial provenance stays in the snapshot. +Asynchronous history remains audit evidence rather than selection authority. + +Matching only by profile was considered, but cannot distinguish two source +steps using the same profile or the original conversation from a newer tab. +Reinterpreting **No profile override** as initial was considered, but would +change existing workflows. Explicit references keep that compatibility path. + +Portable version 2 is used when targets exist. Adding routing fields to version +1 was considered, but an older reader could silently ignore them and deliver a +prompt to another agent. Version rejection is the required compatibility result. diff --git a/docs/plans/workflow-session-targeting/plan.md b/docs/plans/workflow-session-targeting/plan.md new file mode 100644 index 0000000000..1432471e0b --- /dev/null +++ b/docs/plans/workflow-session-targeting/plan.md @@ -0,0 +1,362 @@ +--- +created: 2026-09-09 +status: complete +requirements: + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-001 + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-002 +system_design: + - ../../specs/tasks/system-design/workflow-profile-session-lifecycle.md +legacy_specs: [] +--- + +# Implementation Plan: Workflow Session Targeting + +## Overview + +Allow Review to return to the task's initial agent or an earlier step's +conversation. Keep **When this step starts** and **When this step ends** as +independent controls. Keep lifecycle navigation visible above the recipient list. + +The task system owns this vertical feature because step routing selects the +session that owns task execution. Deliver two complete slices in order: + +1. Tasks 01-04 deliver initial targeting, lifecycle navigation at the top, and + desktop/mobile proof of Sol → Luna → Sol. This is a working product milestone. +2. Tasks 05-08 add earlier-step targets, durable step bindings, author repair + flows, and desktop/mobile proof while retaining the first slice's behavior. + +Both slices are complete. The implementation includes the initial-session and +source-step targeting paths, their desktop and mobile controls, and the +regression evidence listed below. + +## Scope + +### In scope + +- Explicit initial and earlier direct-profile step targets. +- Exact recipient identity, same-profile fresh sessions, and source end policy. +- Durable task bindings, restart, deletion, retry, and failure behavior. +- API, templates, duplication, portable version 2, and workflow sync. +- Desktop popover and phone drawer with lifecycle navigation above search. +- Translated copy, dirty tracking, read-only inspection, and public docs. + +### Out of scope + +- Changing default profile-only routing or reviving terminal conversations. +- Arbitrary task-session IDs in workflow definitions or later-step references. +- Indirect target chains, concurrent agents, Office participant ownership. +- Combining explicit targeting with conditional original-session configuration. +- Automatic deletion of parked sessions or changes to provider models. + +## Technical approach + +### Persist recipients and execution provenance + +Task 01 adds nullable `session_target` with the `initial` variant to models, +REST and MCP, persistence, frontend types, and portable conversion. Unsupported +kinds fail validation. Keep `agent_profile_id` for existing profile-only routing. +Create/update/clear and omitted fields need independent cross-surface tests. +Task 05 extends the same union with `step`; no earlier-step choices appear before +their full backend support exists. + +Update `internal/mcp/server/config_handlers.go` tool schemas/forwarding and +`internal/mcp/handlers/config_workflow_handlers.go` decoding/controller mapping. +Extend handler event parity and server forwarding tests with target payloads. +Change the one envelope version validator in `workflow/models/export.go`; +import and sync already share it. Target-bearing exports use version 2. Version +1 remains readable and emitted for workflows without explicit targets. The +initial-only reader rejects later `step` variants through closed-kind validation. + +For the new column, keep workflow `repository/sqlite.go`, task stub schema and +migrations, defaults/bootstrap inserts, and `config/workflows/loader.go` aligned. +Test built-in null defaults as well as configured template values. The ID-only +`builtin_workflow_step_rows.go` helper is inspection context, not a required edit. + +Initial identity comes from the existing original-session marker. Task 01 adds +a write-once `workflow_initial_session` task metadata snapshot, retaining its +logical profile after session deletion, and a bounded `workflow_session_route` +record for prepared/committed entry retries. Task 02 wires both into creation +and routing. Neither depends on the step-binding table. Preserve existing task +metadata with conditional transactional writes and dialect-specific tests. + +Task 05 adds `task_workflow_session_bindings` for latest source-step selection +only. Task 06 records a binding even when that source reuses a session. Initial +provenance stays in the task snapshot. Add earlier-position validation, source +reference remapping, deletion cleanup, conformance, and stale-operation guards +in this slice. Audit task `workflow.go` deletion paths for table cleanup. + +### Route explicit recipients + +Extract typed target resolution from the growing +`event_handlers_workflow.go` into `workflow_session_target.go`. Use it in +preflight and preparation, including no-session starts, engine entry, manual, +queued, and automatic moves. Explicit targets bypass profile equality as a +reason to ignore `new`; an exact active target with `reuse` remains active. +Keep the existing lifecycle retirement, credentials, queue transfer, pending +move, stop-intent, and completion-signal machinery. + +### Present workflow-aware choices + +In Task 03, offer the initial target and existing profile catalog. In Task 07, +pass edited steps from `workflow-pipeline-editor-panels.tsx` into the selector +and extend `lib/workflows/session-target-options.ts` with earlier-step choices. +Keep a fixed lifecycle entry above search and a single scrolling list beneath. +Offer initial and distinct earlier-step choices before the profile catalog. +Preserve the existing shared settings save/discard coordinator. + +Phone composition reuses `MobilePickerSheet` with a fixed-content slot above +the scrolling children for lifecycle/search; do not copy the shell. A shared +path move is optional, with all imports/tests preserved. Keep the existing +`useResponsiveBreakpoint` phone branch. Use `useTouchDrawer` only when a +pointer-driven disclosure actually needs it, not as a second responsive path. +Use dynamic viewport height, keyboard-aware +containment, safe-area padding, focus return, and 44 px touch controls. Desktop +keeps its normal control density. Shared state and handlers drive both surfaces. + +## ASCII UI preview + +These views record the preview reviewed in conversation. Control order, grouping, +fixed lifecycle navigation, and the phone drawer are required. Box dimensions +and spacing are illustrative; use existing UI primitives and localized labels. + +UI-01 and UI-03 show the final second slice. For the first slice, omit the +Implement row entirely; all other structure and UI-02 remain the same. Task 03 +contains that initial-only excerpt. Task 07 contains the added source choice +and UI-04 repair state. There are no disabled placeholders for future choices. + +### UI-01: Review recipient selector + +Entry: open the agent selector in the Review step. Proposed desktop popover: + +```text ++--------------------------------------------------+ +| Session lifecycle > | +| Reuse on start / Park on end | ++--------------------------------------------------+ +| Search agents or workflow sessions... | ++--------------------------------------------------+ +| WORKFLOW SESSIONS | +| (*) Initial agent session | +| Agent selected when the task starts | +| ( ) Implement / 5.6 Luna | +| Session used by the Implement step | +| | +| AGENT PROFILES | +| ( ) No profile override | +| ( ) Claude / Fable | +| ( ) Codex / 5.6 Sol | +| ( ) Codex / 5.6 Luna | +| ... | ++--------------------------------------------------+ +``` + +Before: lifecycle navigation is below all profile rows in the scrolling region. +After: lifecycle navigation and search stay above the single scrolling list. +Search filters choices, never lifecycle navigation. With no matches, the list +shows a localized empty result while lifecycle navigation remains available. +Read-only workflows permit inspection and navigation but disable mutations. + +### UI-02: Lifecycle settings + +Entry: choose Session lifecycle. Both groups belong to the selected Review step. + +```text ++--------------------------------------------------+ +| < Back Session lifecycle | ++--------------------------------------------------+ +| Target: Initial agent session | +| | +| When this step starts: | +| (*) Reuse an available session | +| Continue the original conversation. | +| If unavailable, start a fresh one. | +| ( ) Start a new session | +| Initial agent profile, fresh conversation. | +| | +| When this step ends: | +| ( ) Complete the session | +| This conversation cannot be reused. | +| (*) Park the session | +| Stop the agent; keep its conversation. | ++--------------------------------------------------+ +``` + +Keep the target label visible and return Back to UI-01. For an earlier-step +target, change the description to that step's conversation. Explain that Plan +must park the initial conversation for Review to reuse it. The existing shared +Save changes action persists the draft; the selector adds no local save button. + +### UI-03: Phone recipient drawer + +Entry: tap the Review agent trigger. The inset bottom drawer uses UI-01's +choices and UI-02's lifecycle view with shared state and handlers. + +```text ++----------------------------------+ +| Review | +| [ Initial agent session v ]| +| | +| +----------------------------+ | +| | Agent session | | +| | Session lifecycle > | | +| | Reuse / Complete | | +| +----------------------------+ | +| | Search... | | +| +----------------------------+ | +| | WORKFLOW SESSIONS | | +| | (*) Initial agent session | | +| | ( ) Implement / 5.6 Luna | | +| | AGENT PROFILES | | +| | ... | | +| +----------------------------+ | +| | Bottom safe-area spacing | | +| +----------------------------+ | ++----------------------------------+ +``` + +The header, lifecycle entry, and search remain outside the scrolling choices. +Constrain the drawer to the dynamic viewport and keep controls reachable with +the keyboard open. Use 44 px phone hit areas. Back/dismiss returns focus +predictably, and the document has no horizontal overflow. + +UI-01 and UI-02 map to AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.8, +001.10, 002.2, 002.6, and 002.11 (same AC prefix). UI-03 also covers +001.13. The work package's desktop and mobile session-targeting Playwright +scenarios prove structure and prompt ownership; ASCII alone is not evidence. + +### UI-04: Repair an invalid source target (second slice) + +Entry: the author reorders or removes Implement, making Review's source invalid. +Desktop inline state: + +```text ++--------------------------------------------------+ +| Review | +| Agent: [ Implement / 5.6 Luna v ]| +| ! Implement must remain an earlier source step. | +| [Choose another target] [Use no profile override] | +| Undo the source edit to keep this target. | ++--------------------------------------------------+ +| Save changes [disabled until references are valid]| ++--------------------------------------------------+ +``` + +Phone uses the same inline error with stacked actions. Choose another target +opens UI-03; a valid choice clears the error without changing lifecycle values. +Use no profile override clears the target explicitly. Discard restores the +saved source and dependent references. Server-side conflicts retain unsaved +drafts and identify dependent steps. Repair/save dependents first when the +shared coordinator cannot safely order all edits. Sync errors direct authors +to the source file. Covers AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.9 and 002.11. + +## Tests + +The following evidence was run after implementation: + +| Acceptance | Evidence | +| --- | --- | +| 002.1, 002.8, 002.9, 002.12 | session target model/controller/service coverage: target shapes, partial updates, authorization, and invalid source edits | +| 002.8 | task/workflow repository and orchestrator coverage: round trips, immutable initial snapshot, prepared/committed routing, and retry behavior | +| 002.8, 002.9 | `task/repository/sqlite/workflow_session_bindings_test.go`: source binding persistence, deletion cleanup, replay, and stale-operation rejection | +| 002.8, 002.9 | `mcp/server/config_handlers_test.go` and `mcp/handlers/config_workflow_handlers_test.go`: target schema, forwarding, event parity, omission, and null | +| 002.8, 002.9 | `workflow/models/export_test.go` and workflow service tests: version 1/2, duplication, import/remap, and source/profile validation | +| 002.1, 002.3, 002.5–002.7 | orchestrator target tests and `workflow-session-targeting.spec.ts`: exact original, same-profile new, missing/terminal target, and current-target no-op | +| 002.4–002.7, 002.10 | source-step runtime E2E and orchestrator tests: exact source step, re-entry, profile changes, and unrelated same-profile sessions | +| 001.6, 001.11, 002.7–002.9 | orchestrator race tests plus desktop/mobile E2E: entry identity, retries, preflight, promotion, and lifecycle controls | +| 001.8–001.10, 001.12–001.13, 002.2, 002.9, 002.11–002.12 | selector, validation, duplication, and dirty-state tests: labels, filtering, pinned lifecycle, save/discard, read-only, and repair rules | + +All AC numbers above use `AC-TASKS-WORKFLOW-PROFILE-SESSIONS-` as their prefix. +New logic uses TDD. Keep tests in focused new files where existing suites exceed +file-size limits. PostgreSQL tests require an isolated test DSN; a skip is not +PostgreSQL evidence. Update required-store/conformance coverage for the task +metadata/column first and binding table second, including previous-stable upgrade behavior. + +Existing lifecycle criteria are regression obligations, not omitted scope: + +| Acceptance | Owner and existing evidence | +| --- | --- | +| 001.1 | Task 02: `TestSwitchSessionForStep_ReusesNonterminalSession` in `event_handlers_workflow_profile_test.go` | +| 001.2 | Task 02: `TestSwitchSessionForStep_NewOnStartParkOnEndRoundTrip` in `event_handlers_workflow_profile_session_policy_test.go` | +| 001.3, 001.4 | Task 02: `TestSwitchSessionForStep_UsesDestinationStartAndSourceEndIndependently` in the same policy test file; assert both source outcomes | +| 001.7 | Task 01: `TestWorkflowStepProfileSessionPoliciesRoundTripAndDefaults` plus existing model/service policy tests | + +Task 06 reruns the same routing regressions after extending target kinds. Only +001.1 and 001.5 were scoped for the new routing distinction; 001.2 was unchanged. + +## E2E tests + +- `e2e/tests/workflow/workflow-session-targeting.spec.ts`, project `chromium`: + author Plan/Implement/Review, select the task's initial profile at creation, + prove Review prompt ownership and session IDs for reuse and fresh modes. + Task 04 owns the initial-only scenario; it cannot depend on later step support. +- `e2e/tests/workflow/mobile-workflow-session-targeting.spec.ts`, project + `mobile-chrome`: author the same choice through the drawer, configure both + lifecycle groups before scrolling a large profile list, save/reload, run to + Review, and inspect the receiving conversation. Cover filtering with no + results, touch sizes, focus return, viewport containment, and horizontal overflow. + `mobile-workflow-step-session-targeting.spec.ts` for equal-profile source + distinction, skip/re-entry behavior, invalid reference repair, and remapping. + It reruns the first slice's two specs to prove the extension preserves them. +- The combined desktop spec covers initial reuse/fresh routing, source-step + routing, and invalid-source repair. The mobile spec covers the responsive + drawer, fixed lifecycle controls, save behavior, and viewport containment. + The desktop suite passed all four scenarios; the mobile suite passed its + lifecycle scenario. +- Extend the existing workflow agent-switch scenarios only where their shared + helpers or selectors change; keep legacy profile-only assertions. + +## Work orders + +- [x] [Task 01: Persist initial recipient contracts](task-01-persist-recipient-contracts.md) +- [x] [Task 02: Route initial workflow recipients](task-02-route-explicit-recipients.md) +- [x] [Task 03: Expose the initial session choice](task-03-expose-session-choices.md) +- [x] [Task 04: Prove initial session targeting](task-04-prove-session-targeting.md) +- [x] [Task 05: Persist source-step targets](task-05-persist-source-step-targets.md) +- [x] [Task 06: Route source-step recipients](task-06-route-source-step-recipients.md) +- [x] [Task 07: Expose source-step choices](task-07-expose-source-step-choices.md) +- [x] [Task 08: Prove source-step targeting](task-08-prove-source-step-targeting.md) + +Execution order: 01 → 02 → 03 → 04 (initial milestone), then 05 → 06 → 07 → 08 +(source-step milestone). No delegated implementation is authorized. +The exact commands belong to each work order. Install workspace dependencies +once before package commands if the worktree has no `apps/node_modules`. + +## Verification results + +Design and implementation validation on 2026-09-09: + +- `rtk python3 scripts/lint-spec-files.test.py`: passed, 30 tests. +- `rtk python3 scripts/lint-spec-files.py --all`: passed. +- `rtk git diff --check`: passed. + +Backend workflow packages: 804 tests passed. MCP schema and parity tests: 16 +passed. Task repository race tests passed; store conformance passed 290 tests. +Orchestrator target and lifecycle race tests passed 180 tests; workflow engine +and step-entry tests passed 230 tests. Backend build and E2E plugin packaging +passed. Frontend focused tests passed 36 tests; typecheck and full web ESLint +passed with no warnings. All translation gates passed. Desktop workflow +targeting E2E passed 4 tests; mobile workflow targeting E2E passed 1 test. +Public documentation validation, full spec lint, and whitespace checks passed. + +The park-by-default follow-up aligned the backend and frontend normalizers, +both SQL schema owners, runtime nil handling, public docs, and the desktop and +phone previews. Final checks passed 69 frontend regressions, 164 focused +orchestrator regressions, 11 race regressions, SQL guard, backend lint, +frontend typecheck and targeted lint, i18n ratchet, spec lint, and public-doc +validation. A full orchestrator attempt reached 1,385 passing tests before the +package's fixed 10-minute timeout; the task-defined focused suite completed. + +The PostgreSQL-specific commands were not run because this environment did not +provide an isolated KANDEV_TEST_POSTGRES_DSN. The backend boot test command was +run but reported no matching tests in the current checkout. + +## Risks + +- Existing same-profile early returns can bypass explicit recipient intent. +- Initial provenance must survive primary changes without acquiring new markers. +- A step binding written before a failed promotion could misroute later work. +- Cached or repeated step-entry events can create duplicate fresh sessions. +- Step references require coordinated remapping and validation on all write paths. +- Version 2 exports require upgraded readers; version 1 routing is preserved. +- `reuse` cannot preserve a conversation that its source step completed. +- Conditional session options remain exclusive; their wording must not imply routing. diff --git a/docs/plans/workflow-session-targeting/task-01-persist-recipient-contracts.md b/docs/plans/workflow-session-targeting/task-01-persist-recipient-contracts.md new file mode 100644 index 0000000000..9f755e77db --- /dev/null +++ b/docs/plans/workflow-session-targeting/task-01-persist-recipient-contracts.md @@ -0,0 +1,129 @@ +--- +id: "01-persist-recipient-contracts" +title: "Persist initial recipient contracts" +status: complete +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-001 + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-002 +acceptance_criteria: + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.7 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.1 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.8 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.9 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.12 +system_design: + - ../../specs/tasks/system-design/workflow-profile-session-lifecycle.md +--- + +# Task 01: Persist initial recipient contracts + +## Summary + +Prepare the initial-target contract and its durable provenance/retry storage. +This first slice has no source-step bindings or reference graph. + +## In scope + +- Nullable `session_target: {kind: initial}`; unknown kinds (including `step` + in this slice) fail validation. Preserve omitted versus explicit null updates. +- REST and MCP target schema, decoding, forwarding, controller validation, + response/event projection, and frontend types. Test create/update/clear parity. +- Workflow column and aligned task stub schema, replay migrations, built-in + null defaults, YAML template loader mapping, import/export, duplication, sync. +- Version 2 for explicit initial targets and version 1 for legacy exports. Modify + `WorkflowExport.Validate`, the shared import/sync version gate. +- Task metadata `workflow_initial_session` write-once snapshot and bounded + `workflow_session_route` prepared/committed record. Add conditional, + transaction-capable repository operations and task-duplication exclusions. +- Reopen, original-session deletion, compare-and-set, rollback, SQLite/PostgreSQL + behavior, and existing required-store/conformance/upgrade coverage. + +## Out of scope + +- Source-step target support, binding table, earlier-position validation. +- Runtime wiring, UI controls, and public documentation. + +## Acceptance + +- REST/MCP preserve target values and partial-update semantics, reject conflicts, + and emit equivalent events with explicit target test inputs. +- Templates, built-in initialization, portable version 1/2, duplication, and + sync preserve initial intent and the reuse/park lifecycle defaults. +- Snapshot writes are immutable; session deletion retains initial profile. + Fresh insertion/prepared route and promotion/committed route have transactional + repository seams. Stale writes and failures preserve unrelated metadata. + +## Verification + +Use TDD for new contract/repository behavior. From the repository root: + +```bash +rtk make -C apps/backend sqlguard +``` + +From `apps/backend`, with an isolated `KANDEV_TEST_POSTGRES_DSN` for PostgreSQL: + +```bash +rtk go test ./internal/workflow/... ./internal/workflowsync/... ./config/workflows/... -count=1 +rtk go test ./internal/mcp/server -run 'Test.*WorkflowStep' -count=1 +rtk go test ./internal/mcp/handlers -run 'TestWorkflowStep.*Parity' -count=1 +rtk go test -race ./internal/task/repository/sqlite -run 'TestWorkflowSessionTarget|TestPostgresWorkflowSessionTarget|Test.*Workflow.*Default|Test.*Bootstrap' -count=1 +rtk go test -race ./internal/persistence/storeconformance -count=1 +rtk go test ./internal/backendapp -run '^TestPostgresBootInitializesRepositories$' -count=1 +``` + +New metadata test names use `TestWorkflowSessionTarget` and +`TestPostgresWorkflowSessionTarget`. Include explicit initial-target payloads in +server and parity tests; old payloads are insufficient. Record PostgreSQL skips. + +## Files likely touched + +- `apps/backend/internal/workflow/models/{models.go,export.go,session_target.go}` +- `apps/backend/internal/workflow/{controller/controller.go,service/service.go,service/sync_apply.go,repository/sqlite.go}` +- `apps/backend/internal/mcp/server/{config_handlers.go,config_handlers_test.go}` +- `apps/backend/internal/mcp/handlers/{config_workflow_handlers.go,workflow_step_parity_test.go}` +- `apps/backend/config/workflows/loader.go` and loader tests +- `apps/backend/internal/task/repository/sqlite/{base_schema.go,base_migrations.go,defaults.go,workspace_bootstrap.go}` +- `apps/backend/internal/task/repository/sqlite/workflow_session_target.go` and tests (new) +- Task models, repository transaction interfaces, session deletion, and task duplication +- `apps/backend/internal/persistence/{requiredstores,storeconformance}/` +- `apps/web/lib/types/http.ts` and related workflow request/response types + +## Dependencies + +None. + +## Risks + +Existing tests do not automatically exercise new fields. The original marker +disappears with its session row unless the task snapshot is recorded first. +Unconditional metadata writes do not provide write-once or retry safety. + +## Inputs + +- Requirement 001.7 and the initial-target portions of requirement 002. +- Design: Explicit recipient contract; Initial provenance and entry routing. +- Existing policy round-trip tests, MCP parity tests, task metadata CAS helpers. +- `builtin_workflow_step_rows.go` only finds IDs; inspect without automatic edits. + +## Parallelism + +`sequential` + +## Results + +Implemented the initial target contract across models, REST, MCP, SQL schema, +workflow templates, export/import, duplication, and frontend types. Added the +initial metadata snapshot and bounded route record used by runtime routing. +Workflow, MCP, repository, SQL guard, and store-conformance checks passed. +The PostgreSQL-specific repository checks were not run because +`KANDEV_TEST_POSTGRES_DSN` was unavailable. The store-conformance run covered +the available SQLite adapter, and the requested PostgreSQL boot test had no +matching test in this checkout. + +A follow-up changed missing and invalid end-policy values, both SQL schema +defaults, and built-in insertion normalization from `complete` to `park`. +Explicit `complete` values continue to round-trip unchanged. diff --git a/docs/plans/workflow-session-targeting/task-02-route-explicit-recipients.md b/docs/plans/workflow-session-targeting/task-02-route-explicit-recipients.md new file mode 100644 index 0000000000..2cd5ba5073 --- /dev/null +++ b/docs/plans/workflow-session-targeting/task-02-route-explicit-recipients.md @@ -0,0 +1,116 @@ +--- +id: "02-route-explicit-recipients" +title: "Route initial workflow recipients" +status: complete +wave: 2 +depends_on: + - "01-persist-recipient-contracts" +plan: "plan.md" +requirements: + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-001 + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-002 +acceptance_criteria: + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.1 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.2 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.3 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.4 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.5 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.6 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.11 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.1 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.3 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.5 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.6 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.7 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.8 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.9 +system_design: + - ../../specs/tasks/system-design/workflow-profile-session-lifecycle.md +--- + +# Task 02: Route initial workflow recipients + +## Summary + +Wire initial targeting through every workflow entry path. Deliver exact original +reuse and fresh initial-profile conversations without source-step support. + +## In scope + +- Record the initial snapshot with first-session creation. Backfill older tasks + through `originalTaskSession`; reject ambiguous or unrecoverable provenance. +- Typed destination selection shared by preflight and preparation, revalidated + under the lifecycle guard. The initial start does not create a throwaway session. +- Exact original reuse, same-profile new, terminal/deleted-original fallback, + logical dynamic-profile launch, ACP and passthrough. +- Manual, queued, automatic, direct-engine and no-session paths. Persist and + resume prepared/committed route state without duplicate fresh allocations. +- Existing queue transfer, pending moves, source-end policies, stamped stops, + promotion rollback, authorization, completion signals, and question barriers. + +## Out of scope + +- Step binding records or earlier-step targets. +- Editor controls and Office routing changes. + +## Acceptance + +- Sol → Luna → initial returns to the exact parked original; `new` creates a + distinct conversation even when profiles match, without changing original identity. +- First creation, deletion, terminal state, restart and retries preserve the + documented target and error behavior. Unknown legacy initial identity fails visibly. +- All entry paths deliver once to the selected session; source lifecycle and + profile-only regression criteria 001.1-001.6 remain intact. + +## Verification + +Write failing initial scenarios in `workflow_session_target_test.go` and +`workflow_session_target_integration_test.go` before implementation. +From `apps/backend`: + +```bash +rtk go test -race ./internal/orchestrator -run 'TestWorkflowSessionTarget' -count=1 +rtk go test ./internal/orchestrator -run 'TestPrepareWorkflowStepSession|TestSwitchSessionForStep|TestResolveStepAgentProfile|TestHandleTaskMoved|TestProcessOnEnter|TestWorkflowStore|TestHandleAgent.*ProfileSessionStopIntent' -count=1 +rtk go test ./internal/workflow/engine/... ./internal/workflow/stepentry/... -count=1 +``` + +Use production creation/promotion wiring for provenance and retry tests. Exercise +crashes after prepared and committed persistence, and fail source cleanup after +destination preparation. Regression mapping is in the plan's Tests section. + +## Files likely touched + +- `apps/backend/internal/orchestrator/workflow_session_target.go` (new) +- `apps/backend/internal/orchestrator/event_handlers_workflow.go` +- `apps/backend/internal/orchestrator/workflow_profile_session_lifecycle.go` +- `apps/backend/internal/orchestrator/{workflow_store.go,workflow_session_config.go,session_ensure.go}` +- First-session creation and promotion paths in orchestrator/executor and task repository +- `apps/backend/internal/orchestrator/workflow_session_target*_test.go` (new) + +## Dependencies + +Task 01 supplies validated initial targets and transactional metadata APIs. + +## Risks + +A same-profile early return can ignore `new`. Re-resolving after preflight can +select another executor. Old entry events must not allocate another conversation +or overwrite a newer route. Keep exact execution stop stamps. + +## Inputs + +- Design: Initial provenance and entry routing; Explicit routing flow. +- Existing original-session helper, profile-switch, rollback, preflight, + passthrough and lifecycle tests. + +## Parallelism + +`sequential` + +## Results + +Implemented exact initial-session reuse, same-profile fresh sessions, terminal +and missing-target fallback, preflight, and prepared/committed route handling +across start, move, and workflow-entry paths. The CREATED-session empty-prompt +regression is covered. Orchestrator race coverage passed 180 tests and workflow +engine/step-entry coverage passed 230 tests. diff --git a/docs/plans/workflow-session-targeting/task-03-expose-session-choices.md b/docs/plans/workflow-session-targeting/task-03-expose-session-choices.md new file mode 100644 index 0000000000..84df39e452 --- /dev/null +++ b/docs/plans/workflow-session-targeting/task-03-expose-session-choices.md @@ -0,0 +1,241 @@ +--- +id: "03-expose-session-choices" +title: "Expose the initial session choice" +status: complete +wave: 3 +depends_on: + - "02-route-explicit-recipients" +plan: "plan.md" +requirements: + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-001 + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-002 +acceptance_criteria: + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.8 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.9 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.10 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.12 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.13 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.6 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.8 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.9 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.11 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.12 +system_design: + - ../../specs/tasks/system-design/workflow-profile-session-lifecycle.md +--- + +# Task 03: Expose the initial session choice + +## Summary + +Extend the step selector with the initial-session choice. Move lifecycle +navigation above search and keep it visible while the recipient list scrolls. + +## In scope + +- Initial-target versus legacy-profile derivation, profile logos, labels, and search. + Do not expose earlier-step choices or placeholders in this slice. +- Initial-agent description and visible reuse/park/terminal fallback explanation. +- Lifecycle navigation pinned above the list, separate start/end view, and + trigger summaries that identify explicit targets. +- Save/discard and dirty tracking for target changes, explicit null clearing, + and failed save preservation. +- Read-only navigation with disabled mutation, conditional-rule exclusion, + without silently removing existing conditional rules. +- Mobile inset drawer, dynamic viewport and keyboard containment, one scroll + owner, safe areas, touch sizing, focus return, and desktop keyboard navigation. +- Reuse MobilePickerSheet and add an optional fixed-content slot above scrolling + children. Preserve existing task, thread, and settings consumers. Keep the + useResponsiveBreakpoint branch; useTouchDrawer is not an extra required path. +- English, pt-pt, zh-cn, zh-hk, zh-tw, and generated pseudo-locale copy. + +## Out of scope + +- Source-step targets, reference repair UI, runtime routing, unrelated layout changes. + +## Acceptance + +- The selector offers initial and legacy profiles without claiming runtime + sessions already exist; target updates clear conflicting profile values. +- Lifecycle navigation is visible before scrolling a long list and with no + search matches. Both viewports retain saved values and predictable focus. +- Dirty/save/discard and read-only behavior include target state, while existing + conditional rules are never silently removed. + +## ASCII UI preview + +These initial-only excerpts use the plan's UI-01 through UI-03 labels. +Source-step rows arrive in Task 07. These views record the reviewed structure. +Control order, grouping, +fixed lifecycle navigation, and the phone drawer are required. Box dimensions +and spacing are illustrative; use existing UI primitives and localized labels. + +### UI-01: Review recipient selector + +Entry: open the agent selector in the Review step. Proposed desktop popover: + +```text ++--------------------------------------------------+ +| Session lifecycle > | +| Reuse on start / Park on end | ++--------------------------------------------------+ +| Search agents or workflow sessions... | ++--------------------------------------------------+ +| WORKFLOW SESSIONS | +| (*) Initial agent session | +| Agent selected when the task starts | +| | +| AGENT PROFILES | +| ( ) No profile override | +| ( ) Claude / Fable | +| ( ) Codex / 5.6 Sol | +| ( ) Codex / 5.6 Luna | +| ... | ++--------------------------------------------------+ +``` + +Before: lifecycle navigation is below all profile rows in the scrolling region. +After: lifecycle navigation and search stay above the single scrolling list. +Search filters choices, never lifecycle navigation. With no matches, the list +shows a localized empty result while lifecycle navigation remains available. +Read-only workflows permit inspection and navigation but disable mutations. + +### UI-02: Lifecycle settings + +Entry: choose Session lifecycle. Both groups belong to the selected Review step. + +```text ++--------------------------------------------------+ +| < Back Session lifecycle | ++--------------------------------------------------+ +| Target: Initial agent session | +| | +| When this step starts: | +| (*) Reuse an available session | +| Continue the original conversation. | +| If unavailable, start a fresh one. | +| ( ) Start a new session | +| Initial agent profile, fresh conversation. | +| | +| When this step ends: | +| ( ) Complete the session | +| This conversation cannot be reused. | +| (*) Park the session | +| Stop the agent; keep its conversation. | ++--------------------------------------------------+ +``` + +Keep the target label visible and return Back to UI-01. Explain that Plan +must park the initial conversation for Review to reuse it. The existing shared +Save changes action persists the draft; the selector adds no local save button. + +### UI-03: Phone recipient drawer + +Entry: tap the Review agent trigger. The inset bottom drawer uses UI-01's +choices and UI-02's lifecycle view with shared state and handlers. + +```text ++----------------------------------+ +| Review | +| [ Initial agent session v ]| +| | +| +----------------------------+ | +| | Agent session | | +| | Session lifecycle > | | +| | Reuse / Complete | | +| +----------------------------+ | +| | Search... | | +| +----------------------------+ | +| | WORKFLOW SESSIONS | | +| | (*) Initial agent session | | +| | AGENT PROFILES | | +| | ... | | +| +----------------------------+ | +| | Bottom safe-area spacing | | +| +----------------------------+ | ++----------------------------------+ +``` + +The header, lifecycle entry, and search remain outside the scrolling choices. +Constrain the drawer to the dynamic viewport and keep controls reachable with +the keyboard open. Use 44 px phone hit areas. Back/dismiss returns focus +predictably, and the document has no horizontal overflow. + +UI-01 and UI-02 map to AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.8, +001.10, 002.6, and 002.11 (same AC prefix). UI-03 also covers +001.13. The work package's desktop and mobile session-targeting Playwright +scenarios prove structure and prompt ownership; ASCII alone is not evidence. + +Preview source: [Plan UI-01 through UI-03](plan.md#ascii-ui-preview). +Keep these view labels and excerpts synchronized with the plan when revised. + +## Verification + +Use TDD for derivation and changed selector state. If dependencies are absent, +run once from `apps`: + +```bash +rtk pnpm install --frozen-lockfile +``` + +From `apps/web`: + +```bash +rtk pnpm exec vitest run lib/workflows/session-target-options.test.ts components/settings/workflow-step-agent-profile-selector.test.tsx components/settings/workflow-dirty-state.test.ts +rtk pnpm exec vitest run components/settings/canvas-host-components.test.tsx components/settings/canvas-host-route.test.tsx +rtk pnpm run typecheck +rtk pnpm exec eslint components/settings/workflow-step-agent-profile-selector.tsx components/settings/workflow-pipeline-editor-panels.tsx components/settings/workflow-step-mutations.ts components/settings/workflow-dirty-state.ts lib/workflows/session-target-options.ts +rtk pnpm exec eslint components/task/mobile/mobile-picker-sheet.tsx +rtk pnpm run i18n:zh-hant +rtk pnpm run i18n:pseudo +rtk pnpm run i18n:check +rtk pnpm run i18n:ratchet +``` + +Include any extracted selector files in targeted eslint. Rendered desktop and +phone verification belongs to Task 04; this work order alone does not establish +mobile behavior evidence. + +## Files likely touched + +- `apps/web/components/settings/workflow-step-agent-profile-selector.tsx` +- `apps/web/components/task/mobile/mobile-picker-sheet.tsx` (shared shell slot) +- `apps/web/components/settings/workflow-pipeline-editor-panels.tsx` +- `apps/web/components/settings/{workflow-step-mutations.ts,workflow-dirty-state.ts}` +- The workflow settings draft/save contributor that constructs step updates +- `apps/web/lib/workflows/session-target-options.ts` and its test (new) +- `apps/web/components/settings/workflow-step-agent-profile-selector.test.tsx` +- `apps/web/components/settings/workflow-dirty-state.test.ts` +- `apps/web/src/locales/*/workflows.json` + +## Dependencies + +Tasks 01 and 02 supply persisted initial targets and working execution semantics. + +## Risks + +The existing selector disables opening read-only data and puts lifecycle +navigation inside the scrolling surface. Both behaviors require explicit tests. +Preserve all shared-shell consumers when adding the fixed-content slot. + +## Parallelism + +`sequential` + +## Inputs + +- Design sections Combined step agent selector and Mobile design contract. +- `mobile-picker-sheet.tsx` fixed-header/scrolling-body exemplar. +- Existing workflow selector tests and shared settings save coordinator. +- `/mobile-parity` and `docs/i18n.md`. + +## Results + +Implemented the combined profile/session selector, lifecycle navigation above +search and scrolling choices, draft validation, translated copy, and shared +desktop/mobile state. Focused frontend tests passed 36 tests, typecheck and +full web lint passed with no warnings, and all translation gates passed. + +A follow-up made the shared desktop/mobile lifecycle surface select **Park the +session** for new or unset policies. Explicitly saved completion remains +selected when reloaded. diff --git a/docs/plans/workflow-session-targeting/task-04-prove-session-targeting.md b/docs/plans/workflow-session-targeting/task-04-prove-session-targeting.md new file mode 100644 index 0000000000..a1f64c02c1 --- /dev/null +++ b/docs/plans/workflow-session-targeting/task-04-prove-session-targeting.md @@ -0,0 +1,123 @@ +--- +id: "04-prove-session-targeting" +title: "Prove initial session targeting" +status: complete +wave: 4 +depends_on: + - "03-expose-session-choices" +plan: "plan.md" +requirements: + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-001 + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-002 +acceptance_criteria: + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.8 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.13 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.1 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.3 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.5 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.6 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.7 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.8 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.11 +system_design: + - ../../specs/tasks/system-design/workflow-profile-session-lifecycle.md +--- + +# Task 04: Prove initial session targeting + +## Summary + +Prove the author's Plan/Implement/Review flow through real UI and mock-agent +execution. Complete the first product milestone with public guidance for initial +routing and version 2. Earlier-step support follows in Tasks 05-08. + +## In scope + +- Desktop authoring with the initial profile selected only at task creation; + assert actual session IDs and unique Review prompt ownership for reuse/new. +- Terminal-source fallback and unchanged legacy profile-only agent switching. +- Mobile authoring with a long profile catalog, no-results search, lifecycle + controls visible at the top, save/reload, and receiving-conversation inspection. +- Phone touch hitboxes, viewport containment, keyboard/dismiss/focus behavior, + safe-area spacing, and zero document horizontal overflow. Inspect rendered + desktop and phone captures from the focused tests. +- Public how-to/explanation for parking Sol and returning from Luna; reference + docs for version 2 targets and version 1 compatibility. + +## Out of scope + +- Earlier-step targets and their binding/repair behavior. +- Broad QA, full test suite, PR publication, actual paid provider execution. + +## Acceptance + +- Desktop and mobile flows prove which conversation received the Review prompt, + not only which profile label appeared in the editor. +- Lifecycle navigation remains reachable throughout long-list scrolling and + filtering; the mobile surface follows the planned drawer geometry. +- Public docs describe implemented behavior and tests/results are recorded in + the plan and work orders, including any environment limitations. + +## Verification + +Load `/e2e` before writing tests and use its causal waits and isolated fixtures. +From `apps/web`, run these sequentially. The managed runner builds the current +backend and web before each invocation; do not use stale binaries: + +```bash +rtk pnpm e2e:run --host --shards 1 --project chromium -- tests/workflow/workflow-session-targeting.spec.ts tests/workflow/workflow-agent-profile.spec.ts +rtk pnpm e2e:run --host --shards 1 --project mobile-chrome -- tests/workflow/mobile-workflow-session-targeting.spec.ts tests/workflow/mobile-workflow-agent-switch.spec.ts +``` + +From the repository root: + +```bash +rtk node --test scripts/validate-public-docs.test.mjs +rtk node scripts/validate-public-docs.mjs +rtk python3 scripts/lint-spec-files.py --all +rtk git diff --check +``` + +Use `/docs-maintainer` when updating the public pages. No public page should +present an unimplemented fallback or target as available. + +## Files likely touched + +- `apps/web/e2e/tests/workflow/workflow-session-targeting.spec.ts` (new) +- `apps/web/e2e/tests/workflow/mobile-workflow-session-targeting.spec.ts` (new) +- `apps/web/e2e/tests/workflow/workflow-agent-switch-helpers.ts` +- `apps/web/e2e/helpers/api-client.ts` and workflow helper types as needed +- `docs/public/tasks-and-workflows.md` (explanation/how-to section) +- `docs/public/workflow-import-export.md` (reference) +- `docs/public/workflow-sync.md` (reference to supported target versions) +- This plan and work-order Results sections + +## Dependencies + +Tasks 01-03 supply the complete initial-target slice. Follow the E2E resource guards. +Complete and verify this milestone before beginning Task 05. Do not describe +source-step targets as available in public docs yet. + +## Risks + +A test that checks only primary-profile labels can miss routing to the wrong +conversation. Large-catalog tests need enough rows to cause internal scrolling. +Mock-agent evidence verifies routing, not provider-specific resume reliability. + +## Parallelism + +`sequential` + +## Inputs + +- Requirement example and design Verification design section. +- Existing workflow agent-switch helpers and mobile session picker test. +- `/e2e`, `/mobile-parity`, and `/docs-maintainer`. + +## Results + +Added desktop and mobile workflow targeting coverage and public documentation +for the initial target behavior. The desktop workflow spec passed four +scenarios, including reuse, fresh routing, source targeting, and repair; the +mobile spec passed its lifecycle/drawer scenario. Public docs and spec +validation passed. diff --git a/docs/plans/workflow-session-targeting/task-05-persist-source-step-targets.md b/docs/plans/workflow-session-targeting/task-05-persist-source-step-targets.md new file mode 100644 index 0000000000..b2db38b468 --- /dev/null +++ b/docs/plans/workflow-session-targeting/task-05-persist-source-step-targets.md @@ -0,0 +1,118 @@ +--- +id: "05-persist-source-step-targets" +title: "Persist source-step targets" +status: complete +wave: 5 +depends_on: + - "04-prove-session-targeting" +plan: "plan.md" +requirements: + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-001 + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-002 +acceptance_criteria: + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.7 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.2 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.8 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.9 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.10 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.12 +system_design: + - ../../specs/tasks/system-design/workflow-profile-session-lifecycle.md +--- + +# Task 05: Persist source-step targets + +## Summary + +Extend the working initial-target contract with direct source-step references +and durable source bindings. Initial provenance and entry retry storage remain +unchanged. + +## In scope + +- Add `kind: step` to backend/frontend/MCP target unions. Validate same workflow, + earlier position, direct profile override, no indirect target, and conditional + rule exclusion. Include incoming-reference validation for edit/reorder/delete. +- Portable `step_position`, template and duplication ID remapping, sync equality, + unresolved profile/source errors, and closed-kind rejection in earlier readers. +- Task-owned source-only `task_workflow_session_bindings` table. Add transactional + conditional upsert/load/cleanup, nullable session pointer, and logical profile. +- Aligned full/stub schema and template/bootstrap mappings where needed. Test + null legacy defaults without blindly changing ID-only helpers. +- Task/workflow/session deletion cleanup, stale-operation rejection, SQL guard, + required-store descriptor and fixed task adapter, replay and upgrade tests. + +## Out of scope + +- Replacing initial metadata with table rows. +- Runtime source selection and UI source choices. + +## Acceptance + +- REST/MCP create/update/clear preserve source references and reject invalid + sources without changing the workflow; initial contracts still pass. +- Export/import, template instantiation, duplication and sync remap references + correctly; readers cannot silently ignore unsupported target kinds. +- Bindings survive reopen, reject stale writes, retain profile after session + deletion, and clean up under task/workflow deletion on both databases. + +## Verification + +Use TDD; from the repository root: + +```bash +rtk make -C apps/backend sqlguard +``` + +From `apps/backend`, use an isolated `KANDEV_TEST_POSTGRES_DSN`: + +```bash +rtk go test ./internal/workflow/... ./internal/workflowsync/... ./config/workflows/... -count=1 +rtk go test ./internal/mcp/server -run 'Test.*WorkflowStep' -count=1 +rtk go test ./internal/mcp/handlers -run 'TestWorkflowStep.*Parity' -count=1 +rtk go test -race ./internal/task/repository/sqlite -run 'TestWorkflowSessionBinding|TestPostgresWorkflowSessionBinding|TestWorkflowSessionTarget|TestPostgresWorkflowSessionTarget|Test.*DeleteWorkflow' -count=1 +rtk go test -race ./internal/persistence/storeconformance -count=1 +rtk go test ./internal/backendapp -run '^TestPostgresBootInitializesRepositories$' -count=1 +``` + +Add explicit step-target payloads to schema/forwarding/event-parity tests. +Report PostgreSQL skips as missing evidence. + +## Files likely touched + +- Task 01's workflow model/controller/repository, MCP server/handler, and loader files +- `apps/backend/internal/task/repository/sqlite/workflow_session_bindings.go` and tests (new) +- `apps/backend/internal/task/repository/sqlite/{base_schema.go,base_migrations.go,workflow.go}` +- Task models/repository interfaces and session/task deletion paths +- `apps/backend/internal/persistence/{requiredstores,storeconformance}/` +- `apps/backend/AGENTS.md` for binding ownership +- `apps/web/lib/types/http.ts` and related workflow contracts + +## Dependencies + +Task 04 completes the initial slice. Task 01's metadata and version contract are +reused; do not recreate them. + +## Risks + +A duplicate/import can retain foreign source IDs. A reorder or deletion can +invalidate dependents. Profile removal and sync must validate the whole result. +Table cleanup must cover workspace reset as well as direct deletion. + +## Inputs + +- Design: Explicit recipient contract; Source-step bindings. +- Existing `RemapStepID`, `pull_from_step_position`, MCP parity tests. +- `workflow.go` is relevant for deletion; `builtin_workflow_step_rows.go` + is an ID lookup, not a target-value mapping. + +## Parallelism + +`sequential` + +## Results + +Implemented the source-step target variant, binding table, migrations, required +store inventory, cleanup, portable step-position remapping, and stale-operation +guards. Binding repository tests and task repository race tests passed; full +store conformance passed 290 tests. diff --git a/docs/plans/workflow-session-targeting/task-06-route-source-step-recipients.md b/docs/plans/workflow-session-targeting/task-06-route-source-step-recipients.md new file mode 100644 index 0000000000..99b5fdd647 --- /dev/null +++ b/docs/plans/workflow-session-targeting/task-06-route-source-step-recipients.md @@ -0,0 +1,109 @@ +--- +id: "06-route-source-step-recipients" +title: "Route source-step recipients" +status: complete +wave: 6 +depends_on: + - "05-persist-source-step-targets" +plan: "plan.md" +requirements: + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-001 + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-002 +acceptance_criteria: + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.1 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.2 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.3 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.4 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.5 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.6 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.11 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.4 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.5 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.6 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.7 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.8 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.9 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.10 +system_design: + - ../../specs/tasks/system-design/workflow-profile-session-lifecycle.md +--- + +# Task 06: Route source-step recipients + +## Summary + +Extend initial routing with exact source-step session selection. Record bindings +when direct-profile steps execute, and keep initial and profile-only behavior. + +## In scope + +- Record the source step's latest successful session choice, including a reused + or already-active session. Couple its binding with the routing commit. +- Exact source-session reuse, same-profile new, skipped-source fresh fallback, + source profile edits, terminal/deleted bindings, and repeated source visits. +- Read source bindings without rewriting them when another step targets them. +- Use existing prepared/committed route records, preflight, queue/pending move + transfer, source-end policy, stop intents and all entry-path integrations. +- Restart, stale operation and promotion races, logical dynamic profiles, + ACP/passthrough and task/workflow authorization. + +## Out of scope + +- UI source picker or changes to initial provenance. +- Indirect references, later steps, Office fan-out. + +## Acceptance + +- Two source steps sharing a profile remain distinct. A later step uses the + selected source's exact latest committed session, not profile recency. +- Skipped, changed-profile, terminal and deleted sources follow documented + fallback without running the source prompt; malformed references fail visibly. +- Retry and rollback preserve bindings and prompt ownership; initial and all + profile-only lifecycle regressions remain passing. + +## Verification + +Add failing tests in `workflow_step_session_target_test.go` and +`workflow_step_session_target_integration_test.go`. From `apps/backend`: + +```bash +rtk go test -race ./internal/orchestrator -run 'TestWorkflowStepSessionTarget|TestWorkflowSessionTarget' -count=1 +rtk go test ./internal/orchestrator -run 'TestPrepareWorkflowStepSession|TestSwitchSessionForStep|TestResolveStepAgentProfile|TestHandleTaskMoved|TestProcessOnEnter|TestWorkflowStore|TestHandleAgent.*ProfileSessionStopIntent' -count=1 +rtk go test ./internal/workflow/engine/... ./internal/workflow/stepentry/... -count=1 +``` + +Test source-step re-entry with stale delivery both before and after the newer +binding commit. Use real repository transactions to prove no orphan binding. + +## Files likely touched + +- `apps/backend/internal/orchestrator/workflow_session_target.go` +- `apps/backend/internal/orchestrator/{event_handlers_workflow.go,workflow_store.go,workflow_profile_session_lifecycle.go}` +- Task repository routing/promotion transaction and source-binding APIs +- `apps/backend/internal/orchestrator/workflow_step_session_target*_test.go` (new) + +## Dependencies + +Task 05 supplies source references and binding persistence. + +## Risks + +Binding before successful selection can make a failed source look available. +Updating the source binding during Review would change its meaning. Old entry +events must not overwrite a newer source visit. + +## Inputs + +- Design: Source-step bindings; Explicit routing flow. +- Tasks 01-02 initial routing and profile lifecycle regression tests. + +## Parallelism + +`sequential` + +## Results + +Implemented source-step resolution and latest-successful-session reuse with +fresh fallback, profile changes, re-entry handling, and unrelated same-profile +session isolation. The source-step runtime E2E delivered the marker to the +recorded source conversation, and orchestrator race coverage passed. diff --git a/docs/plans/workflow-session-targeting/task-07-expose-source-step-choices.md b/docs/plans/workflow-session-targeting/task-07-expose-source-step-choices.md new file mode 100644 index 0000000000..0e41c96711 --- /dev/null +++ b/docs/plans/workflow-session-targeting/task-07-expose-source-step-choices.md @@ -0,0 +1,184 @@ +--- +id: "07-expose-source-step-choices" +title: "Expose source-step choices" +status: complete +wave: 7 +depends_on: + - "06-route-source-step-recipients" +plan: "plan.md" +requirements: + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-001 + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-002 +acceptance_criteria: + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.8 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.10 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.12 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.13 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.2 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.6 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.8 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.9 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.11 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.12 +system_design: + - ../../specs/tasks/system-design/workflow-profile-session-lifecycle.md +--- + +# Task 07: Expose source-step choices + +## Summary + +Add earlier direct-profile step choices to the working initial selector. +Let authors repair invalid references without losing their draft or lifecycle choices. + +## In scope + +- Full edited step list, pure earlier-source derivation, stable IDs, labels and + distinct choices when profiles match. Inherited/default steps are not sources. +- Rename/profile updates, same-workflow earlier-position restrictions, and + source removal/reorder validation with explicit repair. +- Choose another target, Use no profile override, and discard/undo behavior. + Invalid references disable Save; server conflicts retain drafts. +- Safe dependent-before-source save ordering. When no safe sequence exists, + guide separate dependent repair/save before the source change. No new batch API. +- Synced workflow inspection/errors with repair in the source file. +- Extend shared desktop/phone state, fixed lifecycle placement, accessibility + and localized copy. Reuse Task 03's picker shell and responsive branch. + +## Out of scope + +- Backend binding/routing and a second mobile picker implementation. +- Changing lifecycle values as a side effect of target repair. + +## Acceptance + +- Earlier sources are distinct, based on edited configuration, and labelled + with step/profile. Initial targeting and profile-only options remain intact. +- Invalid source edits have explicit repair paths; no silent retargeting, + dropped drafts, invalid persistent intermediates, or lost lifecycle settings. +- Both viewports can choose and repair targets while read-only views expose + errors without mutation controls. + +## ASCII UI preview + +These excerpts use the [plan's view labels](plan.md#ascii-ui-preview). +Grouping, control order and repair actions are required; spacing is illustrative. + +### UI-01: Recipient selector, second-slice addition + +```text ++-------------------------------------------+ +| Session lifecycle > | fixed +| Search agents or workflow sessions... | fixed ++-------------------------------------------+ +| WORKFLOW SESSIONS | scroll +| ( ) Initial agent session | +| (*) Implement / 5.6 Luna | +| ( ) Other earlier step / 5.6 Luna | +| AGENT PROFILES | +| ... | ++-------------------------------------------+ +``` + +Rows reflect actual earlier direct-profile steps; do not add fictional sources. +Selecting Implement changes UI-02's target label and reuse description to its +conversation. The existing lifecycle groups and Save changes action remain. + +### UI-03: Phone recipient drawer, second-slice addition + +```text ++----------------------------------+ +| Review | +| [ Implement / 5.6 Luna v ]| +| | +| +----------------------------+ | +| | Agent session | | +| | Session lifecycle > | | +| | Search... | | +| +----------------------------+ | +| | WORKFLOW SESSIONS | | +| | ( ) Initial agent session | | +| | (*) Implement / 5.6 Luna | | +| | AGENT PROFILES | | +| | ... | | +| +----------------------------+ | +| | Bottom safe-area spacing | | +| +----------------------------+ | ++----------------------------------+ +``` + +Reuse the first slice's fixed header/search, one scrolling list, 44 px phone +hitboxes, keyboard containment, safe area and focus return. + +### UI-04: Invalid source target + +Desktop inline state; phone stacks the actions within the same step card: + +```text ++--------------------------------------------------+ +| Review | +| Agent: [ Implement / 5.6 Luna v ]| +| ! Implement must remain an earlier source step. | +| [Choose another target] [Use no profile override] | +| Undo the source edit to keep this target. | ++--------------------------------------------------+ +| Save changes [disabled until references are valid]| ++--------------------------------------------------+ +``` + +Choose another target opens UI-01/UI-03. A valid choice or explicit clearing +resolves the target error and preserves lifecycle values. Discard restores +saved references. Read-only sync errors point to the source file. + +UI-01 maps to AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.2; UI-03 to 001.13/002.11, +and UI-04 to 002.9/002.11 (same AC prefix). Task 08 owns rendered evidence. + +## Verification + +Use TDD for source derivation and changed draft/save behavior. Dependencies +are already installed by the first slice. From `apps/web`: + +```bash +rtk pnpm exec vitest run lib/workflows/session-target-options.test.ts components/settings/workflow-step-agent-profile-selector.test.tsx components/settings/workflow-dirty-state.test.ts +rtk pnpm run typecheck +rtk pnpm exec eslint components/settings/workflow-step-agent-profile-selector.tsx components/settings/workflow-pipeline-editor-panels.tsx components/settings/workflow-step-mutations.ts components/settings/workflow-dirty-state.ts lib/workflows/session-target-options.ts +rtk pnpm run i18n:zh-hant +rtk pnpm run i18n:pseudo +rtk pnpm run i18n:check +rtk pnpm run i18n:ratchet +``` + +Include changed save-contributor/extracted selector files in targeted tests/lint. + +## Files likely touched + +- `apps/web/components/settings/{workflow-step-agent-profile-selector.tsx,workflow-pipeline-editor-panels.tsx,workflow-step-mutations.ts,workflow-dirty-state.ts}` +- Workflow settings save contributor and targeted draft/save tests +- `apps/web/lib/workflows/session-target-options.ts` and its tests +- `apps/web/src/locales/*/workflows.json` + +## Dependencies + +Tasks 05-06 supply working source references. Task 03 owns shared shell behavior. + +## Risks + +Using saved steps hides draft invalidation. A reorder API can reject a draft +that looked valid before a concurrent edit. Preserve errors and drafts rather +than retrying destructive source edits or silently clearing targets. + +## Inputs + +- Design: Combined step agent selector; Explicit recipient contract. +- Plan UI-01 through UI-04 and Task 03's shared picker implementation. + +## Parallelism + +`sequential` + +## Results + +Implemented earlier-step choices, invalid-source repair actions, dependent-step +validation, save blocking, undo, duplication, sync, and mobile drawer reuse. +Selector, validation, duplication, dirty-state, typecheck, lint, and +translation gates passed. Desktop and mobile authoring coverage passed. diff --git a/docs/plans/workflow-session-targeting/task-08-prove-source-step-targeting.md b/docs/plans/workflow-session-targeting/task-08-prove-source-step-targeting.md new file mode 100644 index 0000000000..c230f30d17 --- /dev/null +++ b/docs/plans/workflow-session-targeting/task-08-prove-source-step-targeting.md @@ -0,0 +1,115 @@ +--- +id: "08-prove-source-step-targeting" +title: "Prove source-step targeting" +status: complete +wave: 8 +depends_on: + - "07-expose-source-step-choices" +plan: "plan.md" +requirements: + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-001 + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-002 +acceptance_criteria: + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.8 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.13 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.2 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.4 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.5 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.6 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.7 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.8 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.9 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.10 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.11 +system_design: + - ../../specs/tasks/system-design/workflow-profile-session-lifecycle.md +--- + +# Task 08: Prove source-step targeting + +## Summary + +Prove exact source-step routing and author repair on desktop and phone. +Extend public docs and retain passing evidence for the initial-target milestone. + +## In scope + +- Two earlier source steps sharing a profile: verify the selected source's + exact session ID receives one prompt; test reuse and fresh behavior. +- Source re-entry and skipped/terminal source fallback, with no source prompt + replay. Backend tests cover remaining concurrency/transport combinations. +- Authoring, save/reload, duplicate/import remapping and synced inspection. +- Draft source reorder/removal, replacement target, explicit clearing, discard, + failed-save preservation, and valid persistence after repair. +- Mobile drawer selection and stacked repair actions, containment, touch size, + focus return, long-list scroll, and no horizontal document overflow. +- Public source-step eligibility, initial-versus-source distinction, lifecycle + fallback, repair instructions and same-version unsupported-kind rejection. + +## Out of scope + +- Paid providers, full suites, broad QA and publication. +- A new UI composition; verify the plan's UI-01 through UI-04. + +## Acceptance + +- Runtime session IDs and unique prompt delivery prove source identity, including + equal-profile sources; first-slice initial flows remain passing. +- Desktop and phone authors can repair invalid sources and persist valid targets. + Inspect rendered captures for the planned drawer and repair geometry. +- Public docs match both completed slices; all work-order results and plan + checkboxes accurately report evidence and environment limitations. + +## Verification + +Load /e2e and use isolated fixtures and causal waits. From `apps/web`, run +sequentially with the managed build enabled: + +```bash +rtk pnpm e2e:run --host --shards 1 --project chromium -- tests/workflow/workflow-step-session-targeting.spec.ts tests/workflow/workflow-session-targeting.spec.ts +rtk pnpm e2e:run --host --shards 1 --project mobile-chrome -- tests/workflow/mobile-workflow-step-session-targeting.spec.ts tests/workflow/mobile-workflow-session-targeting.spec.ts +``` + +From the repository root: + +```bash +rtk node --test scripts/validate-public-docs.test.mjs +rtk node scripts/validate-public-docs.mjs +rtk python3 scripts/lint-spec-files.py --all +rtk git diff --check +``` + +Use /docs-maintainer. No generic verification or PR-publication task is added. + +## Files likely touched + +- `apps/web/e2e/tests/workflow/workflow-step-session-targeting.spec.ts` (new) +- `apps/web/e2e/tests/workflow/mobile-workflow-step-session-targeting.spec.ts` (new) +- Existing workflow agent-switch helpers and E2E API client types +- `docs/public/{tasks-and-workflows.md,workflow-import-export.md,workflow-sync.md}` +- Plan and work-order results + +## Dependencies + +Tasks 05-07 supply source targets. Tasks 01-04 remain the regression baseline. + +## Risks + +Profile labels alone do not prove conversation identity. A test must exercise +the visible repair action and saved result, not seed an already-repaired target. + +## Inputs + +- Requirement 002, design Source-step bindings and explicit repair flow. +- Plan UI-01 through UI-04; initial-slice E2E fixtures and existing tests. + +## Parallelism + +`sequential` + +## Results + +Added source-step runtime and repair coverage to the workflow targeting E2E +specs, with the initial behavior rerun in the same desktop suite. Desktop +workflow targeting passed four tests and mobile targeting passed one test. +Public docs, full spec lint, and whitespace validation passed. diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index 4f997e1480..2e8f59037f 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -436,7 +436,7 @@ New steps allow manual moves by default. **Show in command panel** also defaults | Setting | Effect | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Start step** | Where a task is created when no agent starts with it. Only one step per workflow should be selected. If none is selected, Kandev falls back to the first positional step. This setting places tasks; it never starts agents, which is **Auto-start agent** below. | -| **Agent profile and session handling** | The combined selector overrides the workflow/task profile when entering this step. Its session lifecycle settings control how this step starts and ends when the effective profile changes. Consecutive steps with the same profile keep the current session. The fixed profile override and original-session options are mutually exclusive. | +| **Agent profile and session handling** | The combined selector can choose a profile, the task's initial conversation, or a conversation from an earlier direct-profile step. Its lifecycle settings control how this step starts and ends. The fixed profile override and original-session options are mutually exclusive. | | **Override original session options** | Keeps the original conversation tab while applying model and ACP configuration rules for the task's starting agent family. The options editor appears below WIP settings only when this is checked. | | **Auto-start agent** | Starts an agent whenever a task enters the step. | | **Plan mode** | Enables plan mode when the task enters the step. | @@ -454,8 +454,33 @@ For a profile change, configure two independent settings in the combined selecto - **When this step ends: Complete the session** closes the source session. The workflow cannot reuse it later. - **When this step ends: Park the session** stops the source runtime but keeps the conversation available for reuse or manual follow-up. -Existing steps use **Reuse an available session** and **Complete the session** by -default. A parked session is not an active process. You can answer it later, or +The combined selector can target a specific conversation when a step changes +the agent profile: + +- **Initial agent session** returns to the conversation that the task used at + launch. If that conversation is unavailable, Kandev starts a new conversation + with the target step's profile. +- **Earlier workflow step** returns to the latest successful session recorded + for that earlier direct-profile step. The source step must be earlier and + must select a profile directly. Inherited profiles and indirect targets are + not valid sources. +- **Agent profile** keeps the default profile-based routing. Kandev can select + any eligible conversation for that profile. + +For an explicit target, Kandev does not select an unrelated tab only because it +uses the same profile. **Start a new session** always creates a new conversation, +even when the target profile already has another session. A completed or +missing source conversation uses a new conversation with the target profile. + +If a source step is moved, removed, or changed to an indirect profile, the +workflow editor keeps the invalid target visible. Save changes stays disabled +until you choose another target, clear the target, or undo the source edit. +Synced workflows require the same repair in the source file before the next +sync. + +New or unset steps use **Reuse an available session** and **Park the session** by +default. An explicitly saved **Complete the session** choice remains unchanged. +A parked session is not an active process. You can answer it later, or Kandev can reuse it when a later destination step selects the matching profile and start behavior. If Kandev cannot prepare the destination session or record the parked switch, it keeps the current session recoverable and reports the diff --git a/docs/public/workflow-import-export.md b/docs/public/workflow-import-export.md index 8ee4ffd1f3..32dded4bf6 100644 --- a/docs/public/workflow-import-export.md +++ b/docs/public/workflow-import-export.md @@ -130,6 +130,9 @@ IDs, workspace ID, ordering among workflows, source/sync ownership, style, visib agent_name: Claude Code profile_session_start_policy: reuse profile_session_end_policy: park + session_target: + kind: initial + # A source-step target uses: kind: step and step_position: 1 complete_task_on_enter: false ``` @@ -147,12 +150,23 @@ IDs, workspace ID, ordering among workflows, source/sync ownership, style, visib | `agent_profile` | object | Omitted when unset; exact-match behavior is below. | | `profile_session_start_policy` | enum | `reuse` or `new`; controls whether this destination step reuses the newest eligible nonterminal session for its profile or always starts a fresh conversation. Missing or unknown values use `reuse`. | | `profile_session_end_policy` | enum | `complete` or `park`; controls whether this source step's session is closed or kept available when the workflow leaves it for a different profile. Missing or unknown values use `complete`. | +| `session_target` | object | Optional explicit recipient. Use `{kind: initial}` for the task's launch conversation. Use `{kind: step, step_position: N}` for an earlier direct-profile step. Source-step references use positions so import can remap step IDs. | | `complete_task_on_enter` | boolean | Always exported in version 2. On the final workflow step, `true` marks the task `COMPLETED` when it enters that step. On non-final steps the value is retained but inactive. Version 1 derives the legacy name-based behavior only when this field is absent. | | `auto_advance_requires_signal` | boolean | Always exported. `true` makes `on_turn_complete` transitions wait for `step_complete_kandev`; missing input is `false`. | | `cancel_triggers_turn_complete` | boolean | Always exported. `true` lets an explicit user cancellation run the step's normal `on_turn_complete` actions after the cancelled turn settles; missing input is `false`. Pending clarification and non-user interruption/failure paths are not eligible. | | `wip_limit` | integer | Omitted when `0`. Must be non-negative; `0` is unlimited. | | `pull_from_step_position` | integer | Optional feeder reference using another step's `position`. It must exist, cannot point to itself, and cannot form a pull cycle. | +Version 1 is the legacy format. It remains valid for workflows without an +explicit session target. Version 2 adds `session_target` and explicit +completion booleans. An initial target has only `kind: initial`. A source-step +target has `kind: step` and an earlier `step_position`. The source step must +select a profile directly and must not target another session. + +If a source step is missing, later than its target, or invalid after a sync +edit, import and sync report a validation error. Kandev does not silently pick +another conversation. + `stage_type`, Office participants, recorded decisions, task data, and step history are not portable. Imported steps receive new UUIDs and the default internal stage type. ## Portable events diff --git a/docs/public/workflow-sync.md b/docs/public/workflow-sync.md index 8cf93541ce..d5e410c46e 100644 --- a/docs/public/workflow-sync.md +++ b/docs/public/workflow-sync.md @@ -78,7 +78,7 @@ The status also records `last_synced_at`, `last_ok`, `last_error`, and `last_war Sync reads only immediate files in the configured directory. It does not recurse. Extensions are case-insensitive: `.yml` and `.yaml` use YAML decoding, while `.json` uses JSON decoding; other files and directory entries are ignored. Paths are processed in sorted order. -Every file must use the version 1 or version 2 `kandev_workflow` portable envelope documented in [Workflow Import / Export](workflow-import-export.md). New exports use version 2, which carries explicit completion booleans; version 1 remains accepted for compatibility. A file may contain one or several workflows. The safest authoring loop is to build and test a workflow in a disposable workspace, export it, commit the export, and then configure the target workspace. +Every file must use the version 1 or version 2 `kandev_workflow` portable envelope documented in [Workflow Import / Export](workflow-import-export.md). New exports use version 2, which carries explicit completion booleans and supports explicit session targets; version 1 remains accepted for compatibility. A file may contain one or several workflows. The safest authoring loop is to build and test a workflow in a disposable workspace, export it, commit the export, and then configure the target workspace. ```yaml version: 2 @@ -125,6 +125,12 @@ These rules matter when editing definitions: The portable format does not carry every internal or Office field. Sync reconciles the portable Kanban fields, including `complete_task_on_enter` and `cancel_triggers_turn_complete`, and preserves non-portable internal stage type. Completion is active only for the final step; a retained value on a non-final step becomes active if that step is later moved to the final position. Pending clarifications and non-user interruption/failure paths remain ineligible for cancellation completion. Do not use this facility as an Office-workflow backup. +Version 2 can also select an explicit session recipient. Use `session_target: +{kind: initial}` for the task's launch conversation. Use `session_target: {kind: +step, step_position: N}` for an earlier direct-profile step. Sync remaps the +position to the new step ID. It rejects missing, later, indirect, and inherited +sources. Fix the source file and sync again when a target becomes invalid. + ### Invalid and empty sources Parsing and validation happen per file. If one file is invalid, its error becomes a warning, workflows last synced from that exact file are frozen for that run, and valid files continue. This protects existing workflows from deletion because of one broken edit. Fix the file and sync again. diff --git a/docs/public/workflow-tips.md b/docs/public/workflow-tips.md index 9194ab9d9e..e41773d75f 100644 --- a/docs/public/workflow-tips.md +++ b/docs/public/workflow-tips.md @@ -151,7 +151,8 @@ a new session when none is available. **Start a new session** always creates a fresh conversation. The source step's **Complete the session** setting closes the conversation, while **Park the session** stops the agent and keeps the conversation available for reuse or manual follow-up. These settings default -to reuse on start and complete on end. +to reuse on start and park on end. An explicitly selected completion policy is +preserved. ### Continue a completed conversation diff --git a/docs/specs/guide/plans-and-work-orders.md b/docs/specs/guide/plans-and-work-orders.md index e3197ff10f..8777dd6ab2 100644 --- a/docs/specs/guide/plans-and-work-orders.md +++ b/docs/specs/guide/plans-and-work-orders.md @@ -34,6 +34,48 @@ Use a separate work order when work has an independent result or verification boundary. Do not split work only by backend and frontend layers when a vertical slice can remain functional. +## ASCII UI previews + +When a feature or fix package creates or changes rendered UI, include an +**ASCII UI preview** section in `plan.md` and each work order that changes UI. +Backend-only work orders do not need one. For a small copy or control change, +show only the affected row or region; do not redraw an unrelated page. + +- Give each view a stable label, such as `UI-01: Recipient selector`, and name + its entry point and state. Use fenced `text` blocks with ASCII characters. +- Show the proposed control order, grouping, hierarchy, primary action, and + navigation. Annotate fixed versus scrolling regions outside the drawing. +- Show before/after views when a fix or rearrangement depends on the difference. + Only label a view as current behavior when source or rendered evidence supports it. +- Include desktop and phone compositions when they differ. For shared + composition, one preview with explicit mobile behavior notes is sufficient. + Follow `/mobile-parity`; shrinking the desktop drawing is not a phone design. +- Show changed empty, error, disabled, loading, or expanded states when they + materially affect the requested interaction. Do not add unrelated states. +- State which structural choices are requirements and which details are + illustrative. ASCII spacing is not a pixel specification. Use existing UI + primitives and design tokens, and keep user-facing copy localized. +- Map the preview to the applicable `AC-*` criteria and targeted rendered checks. + Drawings supplement behavior, accessibility, and responsive requirements; + they do not replace them or prove the implementation works. + +The plan owns the combined preview. Each UI work order includes its relevant +view or excerpt with the same label and a link to the plan. This keeps the +implementation packet usable without conversation history. If the design +changes, update both copies and the affected criteria together. A preview must +not silently contradict its requirement or system design. + +In the conversation's final design-package summary, render a compact version +of the proposed UI inline, including a distinct phone view when needed. Link +the complete preview and identify material assumptions. File links alone do +not provide the requested visual review. A sketch does not add an approval +checkpoint or authorize implementation or delegation. + +Implementers read the assigned preview before changing UI and compare the +rendered result against its structural requirements during the work order's +existing verification. Record any unresolved difference or update the package +when the user's direction changes; do not silently drift from the reviewed UI. + ## Verification Every work order owns the verification that proves its implementation result. @@ -44,4 +86,3 @@ package. A low-level work order does not need an artificial browser test. Reference acceptance-criterion IDs from tests when that reference improves traceability. - diff --git a/docs/specs/tasks/README.md b/docs/specs/tasks/README.md index 608e5e05f3..84f6dd1b19 100644 --- a/docs/specs/tasks/README.md +++ b/docs/specs/tasks/README.md @@ -110,7 +110,7 @@ signals, and task-scoped scheduling contracts. - [Workflow Cycle Guardrails](requirements/workflow-cycle-guardrails.md) - [Workflow Duplication](requirements/workflow-duplication.md) - [Explicit Workflow-Step Completion Signal](requirements/workflow-explicit-completion-signal.md) -- [Workflow Profile Session Lifecycle](requirements/workflow-profile-session-lifecycle.md) +- [Workflow Profile Session Lifecycle and Recipients](requirements/workflow-profile-session-lifecycle.md) - [Agent decision recording](requirements/workflow-quorum-decision-recording-agent-surface.md) - [Quorum ordering and concurrency](requirements/workflow-quorum-decision-recording-concurrency.md) - [Quorum diagnostics](requirements/workflow-quorum-decision-recording-diagnostics.md) @@ -174,7 +174,7 @@ signals, and task-scoped scheduling contracts. - [Workflow quorum decision recording](system-design/workflow-quorum-decision-recording.md) - [Workflow Step Agent Start Ownership](system-design/workflow-step-agent-start-ownership.md) - [Workflow Step Fixed-Profile Routing](system-design/workflow-step-fixed-profile-routing.md) -- [Workflow Profile Session Lifecycle](system-design/workflow-profile-session-lifecycle.md) +- [Workflow Profile Session Lifecycle and Recipients](system-design/workflow-profile-session-lifecycle.md) - [Workflow task-step transition ledger](system-design/workflow-task-step-transition-ledger.md) - [Human Assignee](system-design/human-assignee.md) - [Task Create Agent Compatibility Recovery](system-design/task-create-agent-executor-compatibility.md) diff --git a/docs/specs/tasks/requirements/workflow-profile-session-lifecycle.md b/docs/specs/tasks/requirements/workflow-profile-session-lifecycle.md index 2f029d5f4d..5fcb7d4586 100644 --- a/docs/specs/tasks/requirements/workflow-profile-session-lifecycle.md +++ b/docs/specs/tasks/requirements/workflow-profile-session-lifecycle.md @@ -10,10 +10,11 @@ owners: ## Overview -The task system selects the task session that executes each fixed-profile -workflow step. Each step chooses three related settings: +The task system selects the task session that executes each workflow step. +It owns this contract because a step's recipient determines task-session ownership. +Each step chooses three related settings: -- The agent profile that executes the step. +- The initial agent, an earlier step's agent session, or an agent profile. - How the step obtains a session when it starts after a profile switch. - What happens to its session when the workflow leaves it for another profile. @@ -38,17 +39,18 @@ fresh conversation for independent work. ### REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-001: Configurable step profile-session lifecycle **Intent:** Let a workflow author configure each step's conversation boundary -without changing existing workflow behavior. +with a conversation-preserving default and explicit completion when required. **User story:** As a workflow author, I want each step to define how its session starts and ends, so repeated stages use the intended context. #### Acceptance criteria -- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.1:** When a destination step selects +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.1:** When a profile-only destination step selects **Reuse an available session**, Kandev shall reuse the newest eligible nonterminal session for that profile. When no session is available, Kandev - shall create a new session. + shall create a new session. Explicit targets use requirement 002's exact + conversation selection rules. - **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.2:** When a destination step selects **Start a new session**, Kandev shall create a fresh conversation. It shall not reuse another session for that profile. @@ -61,20 +63,29 @@ starts and ends, so repeated stages use the intended context. **Park the session**, Kandev shall stop its runtime and keep the session nonterminal. The conversation shall remain available for reuse or manual follow-up. -- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.5:** When consecutive workflow steps - resolve to the active session's profile, Kandev shall keep the active session. - The start and end settings shall have no effect. +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.5:** For existing profile-only steps, + consecutive steps with the active session's profile shall keep that session + when the destination uses the default or **Reuse an available session** start + behavior. A profile-only destination with **Start a new session** shall + always create a fresh conversation, even when its profile matches the active + session. Explicit initial-session and earlier-step targets follow requirement + 002, including fresh conversations with the same profile. - **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.6:** When Kandev parks a session during a profile switch, its completion or stopped event shall not repeat transition actions for the destination step. - **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.7:** When a workflow step is saved, reloaded, exported, imported, or synchronized, its start and end settings shall round-trip. Missing or invalid values shall use **Reuse an available - session** and **Complete the session**. + session** and **Park the session**. An explicitly saved **Complete the + session** value shall remain selected and shall retain its completion + behavior. - **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.8:** When an author edits a mutable - workflow, one step selector shall show the agent profile and a **Session - lifecycle** setting. The lifecycle setting shall present separate **When this + workflow, one step selector shall show the recipient and a **Session + lifecycle** setting above the searchable choices. That setting shall remain + visible while the choices scroll or search has no results. It shall present separate **When this step starts** and **When this step ends** choices with visible explanations. + For a new or unset step, **Park the session** shall be selected by default on + desktop and phone surfaces. - **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.9:** When the selector shows an agent profile, it shall show the same agent logo used by the new-task profile selector. The workflow-default choice shall use the generic agent icon. @@ -92,6 +103,80 @@ starts and ends, so repeated stages use the intended context. drawer shall have one scroll region, safe-area spacing, keyboard-safe navigation, and no document-level horizontal overflow. +### REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-002: Explicit workflow session recipients + +**Intent:** Return a later step's prompt to the intended conversation without +requiring its profile to be known when the workflow is authored. + +#### Acceptance criteria + +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.1:** Every step shall offer + **Initial agent session**. It shall refer to the task's original agent, + resolved when the task obtains its first session, regardless of later + primary-session or workflow-default changes. +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.2:** The selector shall offer each + earlier step with an explicit profile override, labelled by step and profile. + Two steps using the same profile shall remain distinct choices. Workflow + editing shall not imply that these sessions already exist. Steps that inherit + the workflow default are not source-step choices. **Initial agent session** + reaches the original conversation, not every step that uses the default. +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.3:** With **Initial agent session** + and **Reuse an available session**, a parked original conversation shall + receive the step prompt. An unrelated newer session with the same profile + shall not replace it. +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.4:** With an earlier-step target and + **Reuse an available session**, the latest successfully selected conversation + for that source step shall receive the prompt if it remains eligible. + Re-entering the source step shall update that choice for later steps. +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.5:** With either explicit target and + **Start a new session**, Kandev shall create a fresh conversation using that + target's logical profile, even when the active session uses the same profile. + It shall keep the identity of the original task conversation unchanged. +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.6:** When the referenced conversation + is absent or terminal but its profile is known, **Reuse an available session** + shall create a fresh conversation for that profile. It shall not revive a + terminal conversation or select an unrelated conversation. The selector shall + explain this fallback and the need to park a conversation for later reuse. +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.7:** When a step changes sessions, + its predecessor's end setting shall govern the departing session. Reusing + the already-active target shall keep it active. Each successful entry shall + deliver the step prompt once to the selected session and show it as primary. +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.8:** Saving, reloading, duplicating, + exporting, importing, and synchronizing a workflow shall preserve target + meaning. Runtime bindings shall survive restart but shall not be copied to + another task. Existing workflows without explicit targets shall retain their + profile resolution and lifecycle behavior. +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.9:** An invalid, foreign, removed, + or no-longer-earlier source step shall produce a visible configuration error. + Unknown target kinds or conflicting routing choices shall be rejected. + Runtime resolution failures shall stop entry without sending to another agent. + The editor shall identify each affected step and let the author choose another + valid target, clear the target, or undo the source change before saving. + It shall not silently retarget a step when its source is moved or removed. +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.10:** If a task skips the referenced + step, Kandev shall create a conversation from that step's configured profile. + It shall not execute the skipped step's prompt or actions. If the source + profile changes, later entries shall use its new profile with a fresh + conversation instead of reusing an incompatible earlier binding. +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.11:** Desktop and phone selectors + shall expose the same targets, lifecycle controls, explanations, and saved + state. A phone shall use the existing inset drawer pattern, touch targets of + at least 44 px, one scrolling list, and no document horizontal overflow. + Synced workflows shall expose these values for inspection without editing. +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-002.12:** Conditional **Override original + session options** shall remain a separate behavior. The editor and API shall + prevent combining it with an explicit session target. Choosing a target shall + not silently discard existing conditional rules. + +## Example + +A task starts with Sol, which the user selects at task creation. Plan parks +its session on exit. Implement selects Luna. Review selects **Initial agent +session** and **Reuse an available session**. The Review prompt reaches the +original Sol conversation. Selecting **Start a new session** on Review instead +creates a fresh Sol conversation. Selecting **Implement · Luna** returns to +Implement's eligible conversation, subject to Implement's end setting. + ## Out of scope - A workflow-wide lifecycle setting or workflow-wide override. @@ -104,3 +189,5 @@ starts and ends, so repeated stages use the intended context. - Automatically removing parked or historical sessions. - Changing Office agent-session ownership or automation thread policies. - Moving conditional original-session model settings into this selector. +- Choosing arbitrary session tabs while editing a reusable workflow. +- Referencing later steps, indirect target chains, or concurrent recipient fan-out. diff --git a/docs/specs/tasks/system-design/workflow-profile-session-lifecycle.md b/docs/specs/tasks/system-design/workflow-profile-session-lifecycle.md index 5370caecc5..711da79983 100644 --- a/docs/specs/tasks/system-design/workflow-profile-session-lifecycle.md +++ b/docs/specs/tasks/system-design/workflow-profile-session-lifecycle.md @@ -3,15 +3,17 @@ status: draft system: tasks requirements: - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-001 + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-002 --- # Workflow Step Profile Session Lifecycle System Design ## Purpose and boundaries -The task and workflow system owns fixed-profile routing and task-session -lifecycle. Each workflow step owns its agent profile override and two lifecycle -settings. +The task and workflow system owns session recipients and task-session lifecycle. +Each step owns its recipient and two lifecycle settings. The explicit-target +extension below is a design for implementation, dated 2026-09-09. Existing +profile-only behavior remains the compatibility path. The destination step owns session selection. The source step owns session retirement. The agent runtime still owns process launch, resume, and stop. The @@ -29,6 +31,7 @@ session's model settings without switching profiles. | Requirement | Design section | | --- | --- | | `REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-001` | [Data and contracts](#data-and-contracts), [Control flow](#control-flow), [Failure and recovery](#failure-and-recovery), [Combined step agent selector](#combined-step-agent-selector) | +| `REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-002` | [Explicit recipient contract](#explicit-recipient-contract), [Recipient bindings](#recipient-bindings), [Explicit routing flow](#explicit-routing-flow), [Combined step agent selector](#combined-step-agent-selector) | ## Components and responsibilities @@ -62,22 +65,23 @@ The step stores two independent enums: | `profile_session_end_policy` | `complete` | Complete the source session before runtime stop. | | `profile_session_end_policy` | `park` | Keep the source session nonterminal and stop its runtime. | -The schema defaults are `reuse` and `complete`. Domain constructors, +The schema defaults are `reuse` and `park`. Domain constructors, repository scans, request updates, templates, portable import, and sync use the same defaults for missing or unknown values. -These defaults preserve the actual compatibility behavior of the current -`complete` policy. That policy looks for an eligible nonterminal destination -session and completes the source session. +The `park` default preserves the source conversation when an author has not +made an explicit destructive lifecycle choice. An explicit `complete` value +continues to complete the source session and remains unchanged when loaded. -The unshipped `profile_session_policy` field and its three-value enum are -removed from persistence, domain models, APIs, portable formats, frontend state, -and tests. The implementation does not retain a precedence rule between the old -field and the new fields. +The current implementation uses the two independent lifecycle fields. The +earlier combined `profile_session_policy` contract was replaced; removing it +is not work in the explicit-recipient package. There is no precedence rule +between the retired contract and these fields. The portable step uses `profile_session_start_policy` and -`profile_session_end_policy`. The export version does not change because both -fields are optional. Older readers ignore unknown fields. +`profile_session_end_policy`. These optional lifecycle fields use export +version 1. Explicit recipient targets use version 2, as specified below; +the lifecycle-field compatibility rule does not apply to target routing. A parked source session retains its session ID, task environment, executor profile, messages, ACP resume token, and workflow-switch provenance. Its @@ -93,8 +97,11 @@ consumed tombstone remains durable. 1. Obtain the source step and destination step for the transition. 2. Resolve the destination step's effective profile. -3. If the profile is empty or matches the active session, preserve the current - session. Do not apply either lifecycle setting. +3. For a profile-only step, if the normalized start policy is `reuse` and the + profile is empty or matches the active session, preserve the current + session. If the policy is `new`, create a fresh conversation even when the + profile matches the active session. For an explicit target, use the explicit + routing flow below, including same-profile fresh sessions. 4. Normalize the destination start setting and the source end setting. 5. If the start setting is `reuse`, select the newest eligible nonterminal matching session. Exclude the source session. @@ -110,14 +117,12 @@ consumed tombstone remains durable. 12. When the old execution emits a callback, consume only its matching stamp. Skip turn completion, transition evaluation, and task-state reconciliation. -The existing helper that accepts one destination policy must split its inputs. -Reusable-session lookup accepts the destination start setting. Source cleanup -accepts the source end setting. +The existing handoff accepts separate policies. Reusable-session lookup accepts +the destination start setting. Source cleanup accepts the source end setting. -Legacy transitions and manual moves already resolve both steps. They pass both -steps to session preparation. Direct engine entry paths that currently retain -only the destination must carry the source step ID or normalized end setting -through the transition context or step-entry record. +Legacy transitions and manual moves resolve both steps and pass them to session +preparation. Every entry path, including direct engine entry, must retain the +source step ID or normalized end setting for explicit recipient changes too. The workflow engine core remains unchanged. The engine integration and orchestrator handoff contract change because session routing now needs both sides @@ -145,9 +150,9 @@ Changing a step setting affects later transitions only. ## Persistence -The workflow repository replaces the unshipped step policy column with two -replayable columns for SQLite and PostgreSQL. Create, read, list, and update -queries include both fields. +The two lifecycle columns and their replayable SQLite/PostgreSQL migrations +already exist. Create, read, list, and update queries include both fields. +The new step-column work is the nullable explicit recipient target below. Step export, import, templates, duplication, and synchronized YAML preserve both enums. Sync equality includes both fields. @@ -170,13 +175,20 @@ rows use the same logo and label treatment as the new-task selector. The desktop popover has this hierarchy: -1. The primary view contains the searchable profile list. -2. A **Session lifecycle** row shows the compact start and end summary. -3. The lifecycle view starts with this helper: **These settings apply when the - workflow changes agent profiles.** +1. A fixed **Session lifecycle** row shows the compact start and end summary + above search and recipient choices. It is outside search filtering and the + scrolling list, so many profiles cannot hide it. +2. The list groups **Workflow sessions** (initial, then earlier steps) and + **Agent profiles** (existing default and explicit profile choices). + Earlier-step rows use `Step name · Profile label`; the initial row uses a + generic icon and explains that task creation determines the agent. +3. The lifecycle view explains that the start setting selects the target's + conversation and the end setting applies when leaving that conversation. + Profile-only choices retain their existing same-profile behavior. 4. **When this step starts** offers: - **Reuse an available session.** Continue the most recent available session - for this agent profile. If none is available, start a new session. + for this target. If none is available, start a new session. For an explicit + target, do not imply that any session with the same profile qualifies. - **Start a new session.** Always start a new conversation for this step. 5. **When this step ends** offers: - **Complete the session.** Close this session. The workflow cannot reuse it @@ -193,9 +205,29 @@ Profile health, workflow-default fallback, conditional-session incompatibility, and dirty tracking remain workflow-specific. The selector can reuse hierarchical picker primitives without using model-specific data types. -A synchronized workflow shows all settings and disables changes. When a step has -an incompatible conditional `configure_session` action, the existing profile -restriction remains visible. +A synchronized workflow permits opening and navigating the selector but disables +mutations. When a step has a conditional `configure_session` action, recipient +changes remain disabled with visible guidance to remove the rules first. +Initial targeting does not mean restoring original ACP model settings. + +The closed trigger shows the explicit target's label, including source step +name, rather than displaying an empty profile as **No profile override**. +Draft derivation uses the full edited step list supplied through +`workflow-pipeline-editor-panels.tsx`. Rename and profile edits update labels +immediately; invalid references remain visible until repaired. The save +coordinator owns save/discard, including changes across multiple steps. + +When a draft reorder, source removal, or override removal invalidates a target, +keep the selected source visible with an error and identify each dependent step. +Provide **Choose another target**, **Use no profile override**, and the existing +discard/undo path. Disable Save until the final draft is valid. A replacement +target preserves the start/end policies. Undo restores the previous reference. +Use target repair updates before the source mutation when each intermediate +state is valid; if no safe save order exists, ask the author to repair/save the +dependent steps first. Do not silently clear references or invent an atomic +multi-step API. Server rejection preserves the remaining draft and names the +affected steps, including edits made by another client. Synced workflows show +the error and direct repair to the source file, with no local mutation controls. ## Mobile design contract @@ -204,12 +236,25 @@ restriction remains visible. - **Mobile entry point:** The same trigger appears in the step card. - **Nearest shipped exemplar:** The new-task profile selector supplies `AgentLogo` treatment. `ModelConfigSelector` supplies nested navigation. -- **Hierarchy:** The profile list is the first view. **Session lifecycle** opens - one focused view with the start and end groups. +- **Hierarchy:** A fixed lifecycle row precedes search and grouped recipient + choices. **Session lifecycle** opens one focused view with both groups. - **Presentation:** A phone uses an inset bottom drawer. A desktop uses a popover. - **Geometry:** Each phone row has a 44 px active dimension. The drawer uses `100dvh` constraints, one internal scroll region, and safe-area padding. +- **Surface rationale:** This is a temporary configuration choice, so the + existing inset drawer fits. Reuse `MobilePickerSheet`, which already has + settings consumers, and add a fixed-content slot for lifecycle/search above + its scrolling children. Do not copy its shell. Its implementation is currently + `components/task/mobile/mobile-picker-sheet.tsx`; moving it to a shared path + is optional and must preserve every consumer. Keep desktop density separate + from coarse-pointer hit areas. The search keyboard reduces the list viewport; + lifecycle navigation stays outside that list. +- **Responsive selection:** Retain `useResponsiveBreakpoint` for this + click/search selector's phone drawer and wider popover. Test coarse-pointer + tablet containment and touch targets. `useTouchDrawer` is the existing choice + when a disclosure requires a pointer-driven drawer alternative; it is not a + required second responsive path for this selector. Do not add a parallel hook. - **Navigation:** Back returns to the profile list. Close returns focus to the trigger. The keyboard does not cover profile search. - **Shared logic:** Both viewports share filtering, normalization, draft updates, @@ -217,6 +262,189 @@ restriction remains visible. - **Mobile evidence:** Playwright selects both lifecycle settings, saves, reloads, and checks containment and horizontal overflow. +## Explicit recipient contract + +Add optional `session_target` to `WorkflowStep`, `StepDefinition`, step DTOs, +frontend HTTP/WS types, and workflow drafts: + +```json +{"session_target": {"kind": "initial"}} +{"session_target": {"kind": "step", "step_id": "implement-step-id"}} +``` + +Absence means the existing `agent_profile_id` resolution path. A nonempty +`agent_profile_id` and `session_target` are mutually exclusive. Target kinds +are a closed union. `initial` forbids `step_id`; `step` requires it. Unknown or +malformed values are errors, never default routing. Clearing the field with +explicit null returns to legacy routing; PATCH omission preserves it. + +Both REST and MCP use the same controller target validation. Add target schema +and forwarding support in `internal/mcp/server/config_handlers.go`, then request +decoding/mapping in `internal/mcp/handlers/config_workflow_handlers.go`. Extend +`workflow_step_parity_test.go` with explicit target create/update/clear cases; +its existing payloads do not exercise the new field. Preserve omission versus +null through each layer, including emitted `workflow_step.*` payloads. + +A `step` target must name an earlier position in the same workflow, whose +configuration has a direct profile override and no target of its own. This +bounded source rule avoids recursive references. Workflow transition cycles +remain supported; source availability is conditional on the path taken. +Deletion, reorder, and profile removal must validate incoming references. +Coordinated saves repair dependents before removing their source. A failed or +partial save must preserve unsaved drafts and expose the validation error. + +Persist the target as nullable JSON in `workflow_steps.session_target` using +the repository's existing JSON conventions and replayable migrations on both +databases. Do not overload event actions or `agent_profile_id` with sentinels. +Keep the full workflow schema and the task repository's stub `workflow_steps` +projection in `base_schema.go` aligned. Audit lifecycle-column insert paths in +`defaults.go` and `workspace_bootstrap.go`, and the mapping in +`config/workflows/loader.go`. An intentionally omitted target in built-in rows +must yield the documented legacy null default, with a regression test. Inspect +task `workflow.go` for workflow-deletion cleanup; `builtin_workflow_step_rows.go` +only finds IDs and is not a target-column mapping that needs an automatic edit. +Portable steps represent a source with `step_position`, following +`pull_from_step_position`; two-pass import allocates IDs, then resolves targets. +Duplicate positions and unresolved references are errors. Duplication and +template instantiation remap source IDs through the existing `RemapStepID` map. +Sync equality and remapping include targets. + +Export workflows containing explicit targets with portable version 2. Continue +reading version 1 and emitting it for workflows without targets. Update the +single version gate, `workflow/models.WorkflowExport.Validate` in `export.go`; +`workflowsync/service.go:parseExport` already calls it. Keep decoding and schema +docs aligned. A reader that cannot +interpret routing must reject the version rather than ignore the target and +send prompts to the wrong agent. A mixed export uses version 2 for its envelope. +Reject non-null `session_target` fields in a version 1 document. Recognizing +version 2 never bypasses closed-kind or source-reference validation. + +## Recipient bindings + +### Initial provenance and entry routing + +Deliver initial targeting end to end before source-step targeting. Both remain +in scope; the plan owns the implementation milestones. The first slice adds +no per-step binding table. It accepts only `kind: initial`; unknown kinds are +rejected. The second slice extends the closed union with `kind: step` and +validates it. Both use portable version 2: an initial-only reader must reject +the unknown step kind even though it recognizes the envelope version. + +The original-session marker consumed by `originalTaskSession` remains the +authority for discovering initial identity. Persist a write-once +`workflow_initial_session` object in task metadata with `session_id` and logical +`agent_profile_id`, atomically with first-session creation. This snapshot +survives deletion of the original session and a task changing workflows. +The ID remains historical when its session row is absent; never resolve it +to another task's session. A fresh replacement does not change the snapshot +or acquire the original marker. Do not copy this metadata into duplicated tasks. + +For older tasks without a snapshot, use the existing conservative marker and +timestamp resolver and persist a snapshot only for an unambiguous result. +An absent or ambiguous original is a visible resolution error, not permission +to select the current primary or infer an agent from mutable task assignment. +Snapshot existing original identity before session deletion; if it cannot be +resolved, do not claim that later fresh fallback can recover the missing profile. + +The first slice also adds a bounded `workflow_session_route` metadata object +for the current/latest entry: operation identity, destination step, resolved +target/profile snapshot, source session ID, destination session ID, and phase +(`prepared` or `committed`). It is a retry record, not recipient provenance. +Use conditional task-repository operations within the existing transition and +lifecycle serialization. Fresh session insertion and prepared destination +recording commit together; promotion and committed phase advance together. +Preflight external credentials before that transaction; do not hold a database +transaction while stopping or launching a runtime. Retry continues the recorded +destination and stamped source cleanup rather than allocating another session. + +Reject an obsolete entry against the current transition/entry identity before +replacing this record. A newer entry cannot overtake an unfinished route without +reconciliation. Keep the committed record until a newer valid entry replaces it; +task deletion removes it and task duplication strips it. A deleted recorded +destination fails that entry visibly instead of creating a second destination +under the same operation. Test prepared/committed restart and rollback on both +databases. Extend existing task metadata transaction helpers; do not assume +unconditional `SetTaskMetadataKey` provides compare-and-set or write-once safety. + +### Source-step bindings + +Profile identity alone cannot identify the session selected by a prior step. +The second slice adds task-owned `task_workflow_session_bindings`, keyed by +`(task_id, target_key)`, with `workflow_id`, logical `agent_profile_id`, nullable +`session_id`, and the step-entry operation identity. Target keys are +`step:`; they are internal and never portable. Initial +provenance stays in its existing task snapshot, with no table migration or +duplicate initial row in this slice. + +Each successful entry to a direct-profile step records its selected session +against that step. This includes keeping the current session and reusing a +session originally created elsewhere. Re-entry replaces that step's binding. +A Review entry targeting Implement reads Implement's binding; Review does not +rewrite it. If reuse falls back to a new conversation, later Review entries +still resolve against the source step's binding. This makes the source reference +literal, not a moving alias for every conversation using its profile. + +Persist bindings with the routing commit before any entry prompt or source +retirement. Reuse the existing task transition and step-entry serialization and +operation IDs for idempotence. Retried delivery of one entry reads its committed +destination; it must not create another session for `new`. Stale operations +cannot overwrite a later step binding. Asynchronous `session_step_history` is +audit data and must not become routing authority. + +Session deletion clears the binding's session pointer but retains profile +identity for the documented fresh-session fallback. Task deletion removes its +bindings. Workflow/source-step deletion removes affected step bindings after +reference validation. The initial snapshot survives a task changing workflows. +Bindings never copy into duplicated tasks or workflow exports. Extend the +existing `task` required-store descriptor and fixed conformance adapter; +do not add another schema owner. + +## Explicit routing flow + +1. Resolve a typed destination containing target kind, source identity, logical + profile, and an optional exact session ID. Initial uses its immutable snapshot; + step uses its latest binding and the source step's current direct profile. +2. If no source binding exists because the step was skipped, use its configured + profile with no candidate. A profile change invalidates the old candidate. + Do not run the source step's entry actions. +3. For `reuse`, validate the exact candidate's task, profile, nonterminal state, + and existing lifecycle eligibility. Never call newest-by-profile lookup for + explicit targets. Missing or terminal candidates mean fresh creation; + lookup errors or unresolvable profiles abort rather than masquerading as absence. +4. For `new`, discard the candidate even when its profile matches the active + profile. Reusing the exact active session performs no retirement. Any change + of session uses the source step's end setting, including same-profile changes. +5. Preflight the selected session's executor and credentials. Fresh creation + retains the existing task environment and executor inheritance. Resolve + dynamic logical profiles through the existing dynamic-launch path, never + snapshotting a transient execution candidate as the logical profile. +6. Prepare/promote and persist the destination with the entry identity and any + source-step binding update. On failure, retain the source and previous + binding. Recheck eligibility at promotion; a candidate that became terminal + uses the existing fresh-creation fallback. +7. Transfer queued prompts and pending moves, retire the departing session with + existing stamped stop intent and rollback behavior, then execute entry actions + with the destination ID. Preserve completion-signal and question barriers. + +Use the same target resolver for `preflightWorkflowStepCredentials`, +`prepareWorkflowStepSession`, manual moves, queued moves, automatic advancement, +direct engine entry, and no-session task start. Carry the resolved selection +through preflight and preparation, then revalidate under the lifecycle guard. +Do not independently choose different candidates on either side of preflight. +Transport checks apply after recipient resolution for ACP and passthrough. + +Initial task start binds the first session once; a start step targeting initial +must not create a throwaway session and immediately replace it. Subsequent +entries honor `new`. The source step is required whenever an existing session +is retired. Initial targeting with conditional `configure_session` remains +invalid; that feature still acts only on the immutable original session. + +Explicit resolution failures use existing workflow/session error surfaces and +leave prompts undelivered. Log target kind, source step, destination session, +logical profile, and reuse/fresh reason without prompt content or credentials. +Validate source workflow membership on writes and reads, and task/session pairs +before promotion. A corrupt binding cannot route across tasks or users. + ## Verification design Backend tests prove these four combinations: diff --git a/docs/specs/tasks/system-design/workflow-step-fixed-profile-routing.md b/docs/specs/tasks/system-design/workflow-step-fixed-profile-routing.md index d08d140638..9202b604aa 100644 --- a/docs/specs/tasks/system-design/workflow-step-fixed-profile-routing.md +++ b/docs/specs/tasks/system-design/workflow-step-fixed-profile-routing.md @@ -37,6 +37,13 @@ An empty value uses the workflow profile, then the active session profile. A selected profile stays attached to its own `task_sessions.agent_profile_id`. Routing never rewrites a live session to impersonate another profile. +The planned explicit initial-session and earlier-step targets extend this +profile-only path. Their contract, persistence, and same-profile session +replacement rules are owned by +[Workflow Profile Session Lifecycle](workflow-profile-session-lifecycle.md#explicit-recipient-contract). +The control flow below remains the compatibility behavior for steps without +an explicit target. + `workflow_steps.profile_session_start_policy` governs whether the destination step reuses an eligible session or starts a fresh conversation. The source step's `workflow_steps.profile_session_end_policy` governs whether the replaced