diff --git a/apps/backend/internal/plugins/host_data_mappers.go b/apps/backend/internal/plugins/host_data_mappers.go index 525904bd20..111db1820e 100644 --- a/apps/backend/internal/plugins/host_data_mappers.go +++ b/apps/backend/internal/plugins/host_data_mappers.go @@ -237,18 +237,33 @@ func workflowModelToDTO(w *taskmodels.Workflow) pluginsdk.Workflow { func workflowStepModelToDTO(s *wfmodels.WorkflowStep) pluginsdk.WorkflowStep { return pluginsdk.WorkflowStep{ - ID: s.ID, - WorkflowID: s.WorkflowID, - Name: s.Name, - Position: int32(s.Position), - StageType: string(s.StageType), - Color: s.Color, - IsStartStep: s.IsStartStep, - WIPLimit: int32(s.WIPLimit), - AgentProfileID: s.AgentProfileID, + ID: s.ID, + WorkflowID: s.WorkflowID, + Name: s.Name, + Position: int32(s.Position), + StageType: string(s.StageType), + Color: s.Color, + IsStartStep: s.IsStartStep, + WIPLimit: int32(s.WIPLimit), + AgentProfileID: s.AgentProfileID, + OnEnterActionTypes: onEnterActionTypesToDTO(s.Events.OnEnter), } } +// onEnterActionTypesToDTO exposes only the on_enter action TYPES, order +// preserved as authored. Action Config (target task ids, payloads, participant +// roles, agent profile ids) is deliberately not exposed to plugins. +func onEnterActionTypesToDTO(actions []wfmodels.OnEnterAction) []string { + if len(actions) == 0 { + return nil + } + out := make([]string, len(actions)) + for i, action := range actions { + out[i] = string(action.Type) + } + return out +} + func repositoryModelToDTO(r *taskmodels.Repository) pluginsdk.Repository { return pluginsdk.Repository{ ID: r.ID, diff --git a/apps/backend/internal/plugins/host_data_test.go b/apps/backend/internal/plugins/host_data_test.go index 5334b8ce85..fa0d8328fd 100644 --- a/apps/backend/internal/plugins/host_data_test.go +++ b/apps/backend/internal/plugins/host_data_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "reflect" "testing" "time" @@ -513,7 +514,18 @@ func TestPluginHost_Workflows_SucceedsWithCapability(t *testing.T) { "ws-1": {{ID: "wf-1", WorkspaceID: "ws-1", Name: "Default"}}, } d.steps.steps = map[string][]*wfmodels.WorkflowStep{ - "wf-1": {{ID: "step-1", WorkflowID: "wf-1", Name: "Todo", Position: 0, StageType: wfmodels.StageType("work")}}, + "wf-1": { + {ID: "step-1", WorkflowID: "wf-1", Name: "Todo", Position: 0, StageType: wfmodels.StageType("work")}, + { + ID: "step-2", WorkflowID: "wf-1", Name: "Work", Position: 1, StageType: wfmodels.StageType("custom"), + Events: wfmodels.StepEvents{ + OnEnter: []wfmodels.OnEnterAction{ + {Type: wfmodels.OnEnterAutoStartAgent}, + {Type: wfmodels.OnEnterRunCodeReview, Config: map[string]interface{}{"agent_profile_id": "profile-1"}}, + }, + }, + }, + }, } workflows, _, err := d.host.Workflows().List(context.Background(), "ws-1", pluginsdk.Page{}) @@ -528,8 +540,15 @@ func TestPluginHost_Workflows_SucceedsWithCapability(t *testing.T) { if err != nil { t.Fatalf("ListSteps() unexpected error: %v", err) } - if len(steps) != 1 || steps[0].StageType != "work" { - t.Fatalf("ListSteps() = %+v, want one step with StageType=work", steps) + if len(steps) != 2 || steps[0].StageType != "work" { + t.Fatalf("ListSteps() = %+v, want two steps, first with StageType=work", steps) + } + if steps[0].OnEnterActionTypes != nil { + t.Fatalf("steps[0].OnEnterActionTypes = %+v, want nil for a step with no on_enter actions", steps[0].OnEnterActionTypes) + } + wantTypes := []string{"auto_start_agent", "run_code_review"} + if !reflect.DeepEqual(steps[1].OnEnterActionTypes, wantTypes) { + t.Fatalf("steps[1].OnEnterActionTypes = %+v, want %+v", steps[1].OnEnterActionTypes, wantTypes) } } diff --git a/apps/backend/internal/plugins/host_data_wire_test.go b/apps/backend/internal/plugins/host_data_wire_test.go index 3bf8d1bd50..1e5635ed50 100644 --- a/apps/backend/internal/plugins/host_data_wire_test.go +++ b/apps/backend/internal/plugins/host_data_wire_test.go @@ -38,6 +38,7 @@ import ( analyticsmodels "github.com/kandev/kandev/internal/analytics/models" "github.com/kandev/kandev/internal/plugins/manifest" taskmodels "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" "github.com/kandev/kandev/pkg/pluginsdk" ) @@ -414,3 +415,49 @@ func TestPluginHostData_Wire_SessionsPagination(t *testing.T) { require.False(t, secondPageInfo.HasMore) require.Empty(t, secondPageInfo.NextCursor) } + +// TestPluginHostData_Wire_WorkflowSteps_OnEnterActionTypes proves the full +// model -> DTO -> proto -> gRPC wire -> pluginsdk chain for WorkflowStep's +// on_enter_action_types field: a step with several on_enter actions reports +// their types in authored order, a step with none reports nil (not an empty +// slice) at every hop, and action Config never travels — pluginsdk.WorkflowStep +// has no Config field at all, so there is no wire shape for it to leak +// through. +func TestPluginHostData_Wire_WorkflowSteps_OnEnterActionTypes(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"workflows"}}) + d.steps.steps = map[string][]*wfmodels.WorkflowStep{ + "wf-1": { + { + ID: "step-work", + WorkflowID: "wf-1", + Name: "Work", + Position: 0, + StageType: wfmodels.StageType("work"), + Events: wfmodels.StepEvents{ + OnEnter: []wfmodels.OnEnterAction{ + {Type: wfmodels.OnEnterAutoStartAgent, Config: map[string]interface{}{"agent_profile_id": "profile-1"}}, + {Type: wfmodels.OnEnterRunCodeReview}, + }, + }, + }, + { + ID: "step-todo", + WorkflowID: "wf-1", + Name: "Todo", + Position: 1, + StageType: wfmodels.StageType("custom"), + }, + }, + } + host := dialPluginHostOverWire(t, d.host) + + steps, err := host.Workflows().ListSteps(context.Background(), "wf-1") + require.NoError(t, err) + require.Len(t, steps, 2) + + require.Equal(t, "step-work", steps[0].ID) + require.Equal(t, []string{"auto_start_agent", "run_code_review"}, steps[0].OnEnterActionTypes) + + require.Equal(t, "step-todo", steps[1].ID) + require.Nil(t, steps[1].OnEnterActionTypes) +} diff --git a/apps/backend/pkg/pluginsdk/data_types.go b/apps/backend/pkg/pluginsdk/data_types.go index 76d9188306..b68c72c4f1 100644 --- a/apps/backend/pkg/pluginsdk/data_types.go +++ b/apps/backend/pkg/pluginsdk/data_types.go @@ -508,19 +508,29 @@ type WorkflowStep struct { IsStartStep bool WIPLimit int32 // 0 means unlimited AgentProfileID string + // OnEnterActionTypes lists the on_enter action types configured on this + // step, as authored. This lists what is configured, not a guarantee that + // every listed type executes on every transition path. The ordinary move + // path handles auto_start_agent, configure_session, enable_plan_mode, + // set_session_mode and reset_agent_context. Other action types can run only + // on applicable workflow-engine paths. Config maps are deliberately not + // exposed. Nil when the step has no on_enter actions, never an empty + // non-nil slice. Callers must ignore action types they do not recognize. + OnEnterActionTypes []string } func (s WorkflowStep) toProto() *pluginv1.WorkflowStep { return &pluginv1.WorkflowStep{ - Id: s.ID, - WorkflowId: s.WorkflowID, - Name: s.Name, - Position: s.Position, - StageType: s.StageType, - Color: s.Color, - IsStartStep: s.IsStartStep, - WipLimit: s.WIPLimit, - AgentProfileId: s.AgentProfileID, + Id: s.ID, + WorkflowId: s.WorkflowID, + Name: s.Name, + Position: s.Position, + StageType: s.StageType, + Color: s.Color, + IsStartStep: s.IsStartStep, + WipLimit: s.WIPLimit, + AgentProfileId: s.AgentProfileID, + OnEnterActionTypes: s.OnEnterActionTypes, } } @@ -529,18 +539,29 @@ func workflowStepFromProto(p *pluginv1.WorkflowStep) WorkflowStep { return WorkflowStep{} } return WorkflowStep{ - ID: p.GetId(), - WorkflowID: p.GetWorkflowId(), - Name: p.GetName(), - Position: p.GetPosition(), - StageType: p.GetStageType(), - Color: p.GetColor(), - IsStartStep: p.GetIsStartStep(), - WIPLimit: p.GetWipLimit(), - AgentProfileID: p.GetAgentProfileId(), + ID: p.GetId(), + WorkflowID: p.GetWorkflowId(), + Name: p.GetName(), + Position: p.GetPosition(), + StageType: p.GetStageType(), + OnEnterActionTypes: nonEmptyStrings(p.GetOnEnterActionTypes()), + Color: p.GetColor(), + IsStartStep: p.GetIsStartStep(), + WIPLimit: p.GetWipLimit(), + AgentProfileID: p.GetAgentProfileId(), } } +// nonEmptyStrings enforces the documented nil-not-empty invariant at the SDK +// decode boundary, independent of what a caller happened to serialize onto +// the wire. +func nonEmptyStrings(s []string) []string { + if len(s) == 0 { + return nil + } + return s +} + func workflowStepsFromProto(items []*pluginv1.WorkflowStep) []WorkflowStep { if len(items) == 0 { return nil diff --git a/apps/backend/pkg/pluginsdk/data_types_test.go b/apps/backend/pkg/pluginsdk/data_types_test.go index 7f7a09f5dc..7201315686 100644 --- a/apps/backend/pkg/pluginsdk/data_types_test.go +++ b/apps/backend/pkg/pluginsdk/data_types_test.go @@ -176,6 +176,19 @@ func TestWorkflowStepProtoRoundTrip(t *testing.T) { require.Equal(t, step, workflowStepFromProto(proto)) } +func TestWorkflowStepProtoRoundTrip_OnEnterActionTypes(t *testing.T) { + step := WorkflowStep{ + ID: "step-1", + WorkflowID: "wf-1", + Name: "Work", + Position: 1, + StageType: "work", + OnEnterActionTypes: []string{"auto_start_agent", "run_code_review", "set_session_mode"}, + } + proto := step.toProto() + require.Equal(t, step, workflowStepFromProto(proto)) +} + func TestAgentProfileProtoRoundTrip(t *testing.T) { profile := AgentProfile{ ID: "profile-1", diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go index b7ab626752..4085a68fe0 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go +++ b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go @@ -3610,8 +3610,20 @@ type WorkflowStep struct { WipLimit int32 `protobuf:"varint,8,opt,name=wip_limit,json=wipLimit,proto3" json:"wip_limit,omitempty"` // 0 means unlimited // The agent profile this step runs work under, empty when it inherits. AgentProfileId string `protobuf:"bytes,9,opt,name=agent_profile_id,json=agentProfileId,proto3" json:"agent_profile_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // The on_enter action TYPES configured on this step, as authored + // (e.g. "auto_start_agent", "queue_run"), so a plugin can describe this + // step's intent instead of guessing from the step name or stage_type. + // This lists what is configured, not a guarantee that every listed type + // executes on every transition path: an ordinary task move currently runs + // only a subset (auto_start_agent, configure_session, enable_plan_mode, + // set_session_mode, reset_agent_context); queue_run, + // queue_run_for_each_participant, clear_decisions, and run_code_review + // currently execute only via engine-driven triggers (e.g. Office). Action + // config is deliberately not exposed: it carries target task ids, payloads + // and profile ids. Plugins must ignore action types they do not recognize. + OnEnterActionTypes []string `protobuf:"bytes,10,rep,name=on_enter_action_types,json=onEnterActionTypes,proto3" json:"on_enter_action_types,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *WorkflowStep) Reset() { @@ -3707,6 +3719,13 @@ func (x *WorkflowStep) GetAgentProfileId() string { return "" } +func (x *WorkflowStep) GetOnEnterActionTypes() []string { + if x != nil { + return x.OnEnterActionTypes + } + return nil +} + type ListWorkflowStepsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` WorkflowId string `protobuf:"bytes,1,opt,name=workflow_id,json=workflowId,proto3" json:"workflow_id,omitempty"` @@ -7381,7 +7400,7 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "\x04page\x18\x02 \x01(\v2\x16.kandev.plugin.v1.PageR\x04page\"\x8a\x01\n" + "\x15ListWorkflowsResponse\x128\n" + "\tworkflows\x18\x01 \x03(\v2\x1a.kandev.plugin.v1.WorkflowR\tworkflows\x127\n" + - "\tpage_info\x18\x02 \x01(\v2\x1a.kandev.plugin.v1.PageInfoR\bpageInfo\"\x8f\x02\n" + + "\tpage_info\x18\x02 \x01(\v2\x1a.kandev.plugin.v1.PageInfoR\bpageInfo\"\xc2\x02\n" + "\fWorkflowStep\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1f\n" + "\vworkflow_id\x18\x02 \x01(\tR\n" + @@ -7393,7 +7412,9 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "\x05color\x18\x06 \x01(\tR\x05color\x12\"\n" + "\ris_start_step\x18\a \x01(\bR\visStartStep\x12\x1b\n" + "\twip_limit\x18\b \x01(\x05R\bwipLimit\x12(\n" + - "\x10agent_profile_id\x18\t \x01(\tR\x0eagentProfileId\";\n" + + "\x10agent_profile_id\x18\t \x01(\tR\x0eagentProfileId\x121\n" + + "\x15on_enter_action_types\x18\n" + + " \x03(\tR\x12onEnterActionTypes\";\n" + "\x18ListWorkflowStepsRequest\x12\x1f\n" + "\vworkflow_id\x18\x01 \x01(\tR\n" + "workflowId\"Q\n" + diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.proto b/apps/backend/proto/kandev/plugin/v1/plugin.proto index 11cfb82fd9..a9fa469f0a 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin.proto +++ b/apps/backend/proto/kandev/plugin/v1/plugin.proto @@ -515,6 +515,18 @@ message WorkflowStep { int32 wip_limit = 8; // 0 means unlimited // The agent profile this step runs work under, empty when it inherits. string agent_profile_id = 9; + // The on_enter action TYPES configured on this step, as authored + // (e.g. "auto_start_agent", "queue_run"), so a plugin can describe this + // step's intent instead of guessing from the step name or stage_type. + // This lists what is configured, not a guarantee that every listed type + // executes on every transition path: an ordinary task move currently runs + // only a subset (auto_start_agent, configure_session, enable_plan_mode, + // set_session_mode, reset_agent_context); queue_run, + // queue_run_for_each_participant, clear_decisions, and run_code_review + // currently execute only via engine-driven triggers (e.g. Office). Action + // config is deliberately not exposed: it carries target task ids, payloads + // and profile ids. Plugins must ignore action types they do not recognize. + repeated string on_enter_action_types = 10; } message ListWorkflowStepsRequest { string workflow_id = 1;