feat(plugins): plugins can now tell what moving a task to a step will do (#3053)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Henry
2026-08-26 17:55:19 +00:00
committed by GitHub
parent 5215cd4555
commit 4820d3ede3
7 changed files with 182 additions and 34 deletions
@@ -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,
@@ -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)
}
}
@@ -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)
}
+39 -18
View File
@@ -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
@@ -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",
@@ -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" +
@@ -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;