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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user