735 lines
28 KiB
Go
735 lines
28 KiB
Go
package models
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"maps"
|
|
"math"
|
|
|
|
taskmodels "github.com/kandev/kandev/internal/task/models"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const (
|
|
LegacyExportVersion = 1
|
|
ExportVersion = 2
|
|
ExportType = "kandev_workflow"
|
|
)
|
|
|
|
// WorkflowExport is the portable format for sharing workflows.
|
|
type WorkflowExport struct {
|
|
Version int `json:"version" yaml:"version"`
|
|
Type string `json:"type" yaml:"type"`
|
|
Workflows []WorkflowPortable `json:"workflows" yaml:"workflows"`
|
|
}
|
|
|
|
// AgentProfilePortable stores enough agent profile info for cross-workspace matching.
|
|
type AgentProfilePortable struct {
|
|
AgentName string `json:"agent_name" yaml:"agent_name"`
|
|
Model string `json:"model,omitempty" yaml:"model,omitempty"`
|
|
Mode string `json:"mode,omitempty" yaml:"mode,omitempty"`
|
|
}
|
|
|
|
// AgentProfileResolver resolves an agent profile ID to its portable representation.
|
|
type AgentProfileResolver func(profileID string) *AgentProfilePortable
|
|
|
|
// AgentProfileMatcher finds a matching agent profile ID by agent name, model,
|
|
// and mode. currentID, when non-empty, is the profile already bound (e.g.
|
|
// during workflow-sync reconciliation); implementations should keep it when
|
|
// it still matches the descriptor even if the profile was since disabled -
|
|
// disabling only hides a profile from new selection, it doesn't touch
|
|
// 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"`
|
|
Description string `json:"description,omitempty" yaml:"description,omitempty"`
|
|
Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"`
|
|
AgentProfile *AgentProfilePortable `json:"agent_profile,omitempty" yaml:"agent_profile,omitempty"`
|
|
Steps []StepPortable `json:"steps" yaml:"steps"`
|
|
}
|
|
|
|
// StepPortable is a workflow step without instance-specific fields.
|
|
type StepPortable struct {
|
|
Name string `json:"name" yaml:"name"`
|
|
Position int `json:"position" yaml:"position"`
|
|
Color string `json:"color" yaml:"color"`
|
|
Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"`
|
|
Events StepEvents `json:"events" yaml:"events"`
|
|
IsStartStep bool `json:"is_start_step" yaml:"is_start_step"`
|
|
ShowInCommandPanel bool `json:"show_in_command_panel" yaml:"show_in_command_panel"`
|
|
AllowManualMove bool `json:"allow_manual_move" yaml:"allow_manual_move"`
|
|
AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty" yaml:"auto_archive_after_hours,omitempty"`
|
|
AgentProfile *AgentProfilePortable `json:"agent_profile,omitempty" yaml:"agent_profile,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"`
|
|
AutoAdvanceRequiresSignal bool `json:"auto_advance_requires_signal" yaml:"auto_advance_requires_signal"`
|
|
CancelTriggersTurnComplete bool `json:"cancel_triggers_turn_complete" yaml:"cancel_triggers_turn_complete"`
|
|
CompleteTaskOnEnter bool `json:"complete_task_on_enter" yaml:"complete_task_on_enter"`
|
|
WIPLimit int `json:"wip_limit,omitempty" yaml:"wip_limit,omitempty"`
|
|
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
|
|
// version-2 completion field. The domain field remains a bool so callers that
|
|
// build exports in Go keep the same ergonomic API.
|
|
func (s *StepPortable) UnmarshalJSON(data []byte) error {
|
|
type plainStepPortable StepPortable
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &fields); err != nil {
|
|
return err
|
|
}
|
|
if raw, ok := fields["complete_task_on_enter"]; ok && bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
|
return fmt.Errorf("complete_task_on_enter must be a boolean, not null")
|
|
}
|
|
var decoded plainStepPortable
|
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
|
return err
|
|
}
|
|
*s = StepPortable(decoded)
|
|
_, s.completionTaskOnEnterPresent = fields["complete_task_on_enter"]
|
|
s.completionTaskOnEnterDecoded = true
|
|
return nil
|
|
}
|
|
|
|
// UnmarshalYAML records whether a decoded document explicitly supplied the
|
|
// version-2 completion field.
|
|
func (s *StepPortable) UnmarshalYAML(node *yaml.Node) error {
|
|
type plainStepPortable StepPortable
|
|
if node.Kind == yaml.MappingNode {
|
|
for i := 0; i+1 < len(node.Content); i += 2 {
|
|
if node.Content[i].Value != "complete_task_on_enter" {
|
|
continue
|
|
}
|
|
valueNode := node.Content[i+1]
|
|
if valueNode.Tag == "!!null" || valueNode.ShortTag() == "!!null" {
|
|
return fmt.Errorf("complete_task_on_enter must be a boolean, not null")
|
|
}
|
|
}
|
|
}
|
|
var decoded plainStepPortable
|
|
if err := node.Decode(&decoded); err != nil {
|
|
return err
|
|
}
|
|
*s = StepPortable(decoded)
|
|
present := false
|
|
if node.Kind == yaml.MappingNode {
|
|
for i := 0; i+1 < len(node.Content); i += 2 {
|
|
if node.Content[i].Value == "complete_task_on_enter" {
|
|
present = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
s.completionTaskOnEnterPresent = present
|
|
s.completionTaskOnEnterDecoded = true
|
|
return nil
|
|
}
|
|
|
|
func (s StepPortable) completionTaskOnEnterIsExplicit() bool {
|
|
if !s.completionTaskOnEnterDecoded {
|
|
return true
|
|
}
|
|
return s.completionTaskOnEnterPresent
|
|
}
|
|
|
|
func (s *StepPortable) markCompletionTaskOnEnterExplicit() {
|
|
s.completionTaskOnEnterPresent = true
|
|
}
|
|
|
|
// 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 {
|
|
export, err := BuildWorkflowExportWithError(workflows, stepsByWorkflow, resolveProfile)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return export
|
|
}
|
|
|
|
// 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))
|
|
for _, s := range steps {
|
|
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,
|
|
Color: s.Color,
|
|
Prompt: s.Prompt,
|
|
Events: ConvertReviewProfileToPortable(convertStepIDToPosition(s.Events, idToPos), resolveProfile),
|
|
IsStartStep: s.IsStartStep,
|
|
ShowInCommandPanel: s.ShowInCommandPanel,
|
|
AllowManualMove: s.AllowManualMove,
|
|
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,
|
|
WIPLimit: s.WIPLimit,
|
|
}
|
|
if pos, ok := idToPos[s.PullFromStepID]; ok {
|
|
sp.PullFromStepPosition = &pos
|
|
}
|
|
if resolveProfile != nil && s.AgentProfileID != "" {
|
|
sp.AgentProfile = resolveProfile(s.AgentProfileID)
|
|
}
|
|
portableSteps = append(portableSteps, sp)
|
|
}
|
|
|
|
wp := WorkflowPortable{
|
|
Name: wf.Name,
|
|
Description: wf.Description,
|
|
Prompt: wf.Prompt,
|
|
Steps: portableSteps,
|
|
}
|
|
if resolveProfile != nil && wf.AgentProfileID != "" {
|
|
wp.AgentProfile = resolveProfile(wf.AgentProfileID)
|
|
}
|
|
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.
|
|
func (e *WorkflowExport) Validate() error {
|
|
if e.Version != LegacyExportVersion && e.Version != ExportVersion {
|
|
return fmt.Errorf("unsupported export version: %d (expected %d or %d)", e.Version, LegacyExportVersion, ExportVersion)
|
|
}
|
|
if e.Type != ExportType {
|
|
return fmt.Errorf("unsupported export type: %q (expected %q)", e.Type, ExportType)
|
|
}
|
|
if err := e.NormalizeCompletionPolicy(); err != nil {
|
|
return err
|
|
}
|
|
if len(e.Workflows) == 0 {
|
|
return fmt.Errorf("export contains no workflows")
|
|
}
|
|
for i, wf := range e.Workflows {
|
|
if wf.Name == "" {
|
|
return fmt.Errorf("workflow %d: name is required", i)
|
|
}
|
|
positions := make(map[int]bool, len(wf.Steps))
|
|
for j, step := range wf.Steps {
|
|
if step.Name == "" {
|
|
return fmt.Errorf("workflow %d step %d: name is required", i, j)
|
|
}
|
|
if positions[step.Position] {
|
|
return fmt.Errorf("workflow %d: duplicate step position %d", i, step.Position)
|
|
}
|
|
positions[step.Position] = true
|
|
if e.Version == ExportVersion && !step.completionTaskOnEnterIsExplicit() {
|
|
return fmt.Errorf("workflow %d step %d: complete_task_on_enter must be explicitly set", i, j)
|
|
}
|
|
if err := validateOnEnterActions(step); err != nil {
|
|
return fmt.Errorf("workflow %d step %d: %w", i, j, err)
|
|
}
|
|
if step.WIPLimit < 0 {
|
|
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)
|
|
}
|
|
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
|
|
}
|
|
|
|
// NormalizeCompletionPolicy converts version-1 exports at the import/sync
|
|
// boundary. Version 1 had no explicit completion field, so only its final
|
|
// step inherits the legacy terminal-name convention. Version-2 values are
|
|
// never inferred or changed.
|
|
func (e *WorkflowExport) NormalizeCompletionPolicy() error {
|
|
if e.Version != LegacyExportVersion {
|
|
return nil
|
|
}
|
|
for workflowIndex := range e.Workflows {
|
|
workflow := &e.Workflows[workflowIndex]
|
|
if len(workflow.Steps) == 0 {
|
|
continue
|
|
}
|
|
maxPosition := workflow.Steps[0].Position
|
|
for _, step := range workflow.Steps[1:] {
|
|
if step.Position > maxPosition {
|
|
maxPosition = step.Position
|
|
}
|
|
}
|
|
for stepIndex := range workflow.Steps {
|
|
step := &workflow.Steps[stepIndex]
|
|
if step.completionTaskOnEnterDecoded && step.completionTaskOnEnterPresent {
|
|
continue
|
|
}
|
|
step.CompleteTaskOnEnter = step.Position == maxPosition && IsTerminalStepName(step.Name)
|
|
step.markCompletionTaskOnEnterExplicit()
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validatePullSourceRefs(steps []StepPortable, validPositions map[int]bool) error {
|
|
pullSources := make(map[int]int, len(steps))
|
|
for _, step := range steps {
|
|
if step.PullFromStepPosition == nil {
|
|
continue
|
|
}
|
|
pos := *step.PullFromStepPosition
|
|
if pos == step.Position {
|
|
return fmt.Errorf("step %q: pull_from_step_position cannot reference itself", step.Name)
|
|
}
|
|
if !validPositions[pos] {
|
|
return fmt.Errorf("step %q: pull_from_step_position %d does not match any step", step.Name, pos)
|
|
}
|
|
pullSources[step.Position] = pos
|
|
}
|
|
if hasPullSourceCycle(pullSources) {
|
|
return fmt.Errorf("pull_from_step_position cannot create a pull cycle")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hasPullSourceCycle(pullSources map[int]int) bool {
|
|
const (
|
|
visiting = 1
|
|
visited = 2
|
|
)
|
|
|
|
states := make(map[int]int, len(pullSources))
|
|
for start := range pullSources {
|
|
if states[start] == visited {
|
|
continue
|
|
}
|
|
current := start
|
|
path := make([]int, 0)
|
|
for states[current] != visited {
|
|
if states[current] == visiting {
|
|
return true
|
|
}
|
|
states[current] = visiting
|
|
path = append(path, current)
|
|
next, ok := pullSources[current]
|
|
if !ok {
|
|
break
|
|
}
|
|
current = next
|
|
}
|
|
for _, position := range path {
|
|
states[position] = visited
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// PullFromStepID maps the portable pull-source position to a workflow-step ID.
|
|
func (s StepPortable) PullFromStepID(posToID map[int]string) string {
|
|
if s.PullFromStepPosition == nil {
|
|
return ""
|
|
}
|
|
return posToID[*s.PullFromStepPosition]
|
|
}
|
|
|
|
// validateOnEnterActions rejects malformed on_enter actions in a portable step.
|
|
// Currently this guards set_session_mode, which requires a non-empty string
|
|
// "mode" config — without it the action is silently dropped at compile time, so
|
|
// 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 := 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 {
|
|
if a.Type == OnTurnStartMoveToStep {
|
|
if err := checkPositionRef(a.Config, validPositions); err != nil {
|
|
return fmt.Errorf("step %q on_turn_start: %w", step.Name, err)
|
|
}
|
|
}
|
|
}
|
|
for _, a := range step.Events.OnTurnComplete {
|
|
if a.Type == OnTurnCompleteMoveToStep {
|
|
if err := checkPositionRef(a.Config, validPositions); err != nil {
|
|
return fmt.Errorf("step %q on_turn_complete: %w", step.Name, err)
|
|
}
|
|
}
|
|
}
|
|
if err := validateGenericStepPositionRefs(step, validPositions); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateGenericStepPositionRefs checks move_to_step position refs across
|
|
// the seven ADR-0004 Phase 2 GenericAction triggers.
|
|
func validateGenericStepPositionRefs(step StepPortable, validPositions map[int]bool) error {
|
|
triggers := map[string][]GenericAction{
|
|
"on_comment": step.Events.OnComment,
|
|
"on_blocker_resolved": step.Events.OnBlockerResolved,
|
|
"on_children_completed": step.Events.OnChildrenCompleted,
|
|
"on_approval_resolved": step.Events.OnApprovalResolved,
|
|
"on_heartbeat": step.Events.OnHeartbeat,
|
|
"on_budget_alert": step.Events.OnBudgetAlert,
|
|
"on_agent_error": step.Events.OnAgentError,
|
|
}
|
|
for _, trigger := range genericTriggerOrder {
|
|
for _, a := range triggers[trigger] {
|
|
if a.Type == GenericActionMoveToStep {
|
|
if err := checkPositionRef(a.Config, validPositions); err != nil {
|
|
return fmt.Errorf("step %q %s: %w", step.Name, trigger, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// genericTriggerOrder fixes iteration order for validateGenericStepPositionRefs
|
|
// so error messages are deterministic.
|
|
var genericTriggerOrder = []string{
|
|
"on_comment", "on_blocker_resolved", "on_children_completed",
|
|
"on_approval_resolved", "on_heartbeat", "on_budget_alert", "on_agent_error",
|
|
}
|
|
|
|
func checkPositionRef(config map[string]any, validPositions map[int]bool) error {
|
|
if config == nil {
|
|
return fmt.Errorf("move_to_step action missing config")
|
|
}
|
|
pos, exists := config["step_position"]
|
|
if !exists {
|
|
return fmt.Errorf("move_to_step action missing step_position")
|
|
}
|
|
posInt, ok := toInt(pos)
|
|
if !ok {
|
|
return fmt.Errorf("step_position has unexpected type %T", pos)
|
|
}
|
|
if !validPositions[posInt] {
|
|
return fmt.Errorf("step_position %d does not match any step", posInt)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// convertStepIDToPosition rewrites move_to_step events: step_id → step_position.
|
|
func convertStepIDToPosition(events StepEvents, idToPos map[string]int) StepEvents {
|
|
return remapStepEvents(events, "step_id", "step_position", func(v any) (any, bool) {
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
pos, found := idToPos[s]
|
|
return pos, found
|
|
})
|
|
}
|
|
|
|
// ReviewAgentProfilePortableKey is the on_enter config key that carries a
|
|
// run_code_review action's reviewing profile in portable form. The instance
|
|
// `agent_profile_id` is meaningless in another workspace, so export swaps it for
|
|
// the {agent_name, model, mode} triple and import matches it back.
|
|
const ReviewAgentProfilePortableKey = "agent_profile"
|
|
|
|
// ConvertReviewProfileToPortable rewrites run_code_review on_enter actions,
|
|
// replacing `agent_profile_id` with a portable `agent_profile` descriptor.
|
|
// An unresolvable profile has its key dropped, so the imported action falls back
|
|
// to the code-review utility agent instead of carrying a dangling ID.
|
|
func ConvertReviewProfileToPortable(events StepEvents, resolveProfile AgentProfileResolver) StepEvents {
|
|
return remapReviewProfile(events, ReviewAgentProfileConfigKey, ReviewAgentProfilePortableKey, func(v any) (any, bool) {
|
|
profileID, ok := v.(string)
|
|
if !ok || profileID == "" || resolveProfile == nil {
|
|
return nil, false
|
|
}
|
|
portable := resolveProfile(profileID)
|
|
if portable == nil {
|
|
return nil, false
|
|
}
|
|
return map[string]any{
|
|
"agent_name": portable.AgentName,
|
|
"model": portable.Model,
|
|
"mode": portable.Mode,
|
|
}, true
|
|
})
|
|
}
|
|
|
|
// ConvertReviewProfileToID rewrites run_code_review on_enter actions, replacing
|
|
// a portable `agent_profile` descriptor with a local `agent_profile_id`.
|
|
func ConvertReviewProfileToID(events StepEvents, matchProfile AgentProfileMatcher) StepEvents {
|
|
return remapReviewProfile(events, ReviewAgentProfilePortableKey, ReviewAgentProfileConfigKey, func(v any) (any, bool) {
|
|
descriptor, ok := v.(map[string]any)
|
|
if !ok || matchProfile == nil {
|
|
return nil, false
|
|
}
|
|
agentName, _ := descriptor["agent_name"].(string)
|
|
model, _ := descriptor["model"].(string)
|
|
mode, _ := descriptor["mode"].(string)
|
|
profileID := matchProfile(agentName, model, mode, "")
|
|
if profileID == "" {
|
|
return nil, false
|
|
}
|
|
return profileID, true
|
|
})
|
|
}
|
|
|
|
// remapReviewProfile rewrites the profile key on every run_code_review on_enter
|
|
// action, leaving other actions and triggers untouched. When the lookup fails,
|
|
// the source key is removed rather than preserved, so no instance-specific or
|
|
// unmatched reference survives the round trip.
|
|
func remapReviewProfile(events StepEvents, fromKey, toKey string, lookup func(any) (any, bool)) StepEvents {
|
|
result := events
|
|
result.OnEnter = make([]OnEnterAction, 0, len(events.OnEnter))
|
|
for _, a := range events.OnEnter {
|
|
if a.Type == OnEnterRunCodeReview && a.Config != nil {
|
|
if cfg, ok := remapConfigKey(a.Config, fromKey, toKey, lookup); ok {
|
|
a = OnEnterAction{Type: a.Type, Config: cfg}
|
|
} else if _, exists := a.Config[fromKey]; exists {
|
|
cfg := make(map[string]any, len(a.Config))
|
|
maps.Copy(cfg, a.Config)
|
|
delete(cfg, fromKey)
|
|
a = OnEnterAction{Type: a.Type, Config: cfg}
|
|
}
|
|
}
|
|
result.OnEnter = append(result.OnEnter, a)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ConvertPositionToStepID rewrites move_to_step events: step_position → step_id.
|
|
// posToID maps position → new step ID.
|
|
func ConvertPositionToStepID(events StepEvents, posToID map[int]string) StepEvents {
|
|
return remapStepEvents(events, "step_position", "step_id", func(v any) (any, bool) {
|
|
pos, ok := toInt(v)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
id, found := posToID[pos]
|
|
return id, found
|
|
})
|
|
}
|
|
|
|
// remapStepEvents rewrites move_to_step config across every trigger —
|
|
// OnTurnStart/OnTurnComplete plus the seven ADR-0004 Phase 2 GenericAction
|
|
// triggers — replacing fromKey with toKey using the provided lookup function.
|
|
// OnEnter and OnExit carry no step_id/step_position references and are
|
|
// copied through unchanged.
|
|
func remapStepEvents(events StepEvents, fromKey, toKey string, lookup func(any) (any, bool)) StepEvents {
|
|
result := StepEvents{
|
|
OnEnter: append([]OnEnterAction{}, events.OnEnter...),
|
|
OnExit: append([]OnExitAction{}, events.OnExit...),
|
|
}
|
|
for _, a := range events.OnTurnStart {
|
|
if a.Type == OnTurnStartMoveToStep {
|
|
if cfg, ok := remapConfigKey(a.Config, fromKey, toKey, lookup); ok {
|
|
a = OnTurnStartAction{Type: a.Type, Config: cfg}
|
|
}
|
|
}
|
|
result.OnTurnStart = append(result.OnTurnStart, a)
|
|
}
|
|
for _, a := range events.OnTurnComplete {
|
|
if a.Type == OnTurnCompleteMoveToStep {
|
|
if cfg, ok := remapConfigKey(a.Config, fromKey, toKey, lookup); ok {
|
|
a = OnTurnCompleteAction{Type: a.Type, Config: cfg}
|
|
}
|
|
}
|
|
result.OnTurnComplete = append(result.OnTurnComplete, a)
|
|
}
|
|
result.OnComment = remapGenericActionsConfigKey(events.OnComment, fromKey, toKey, lookup)
|
|
result.OnBlockerResolved = remapGenericActionsConfigKey(events.OnBlockerResolved, fromKey, toKey, lookup)
|
|
result.OnChildrenCompleted = remapGenericActionsConfigKey(events.OnChildrenCompleted, fromKey, toKey, lookup)
|
|
result.OnApprovalResolved = remapGenericActionsConfigKey(events.OnApprovalResolved, fromKey, toKey, lookup)
|
|
result.OnHeartbeat = remapGenericActionsConfigKey(events.OnHeartbeat, fromKey, toKey, lookup)
|
|
result.OnBudgetAlert = remapGenericActionsConfigKey(events.OnBudgetAlert, fromKey, toKey, lookup)
|
|
result.OnAgentError = remapGenericActionsConfigKey(events.OnAgentError, fromKey, toKey, lookup)
|
|
return result
|
|
}
|
|
|
|
// remapGenericActionsConfigKey applies remapConfigKey to every move_to_step
|
|
// GenericAction in actions, leaving other action types untouched.
|
|
func remapGenericActionsConfigKey(actions []GenericAction, fromKey, toKey string, lookup func(any) (any, bool)) []GenericAction {
|
|
result := make([]GenericAction, 0, len(actions))
|
|
for _, a := range actions {
|
|
if a.Type == GenericActionMoveToStep {
|
|
if cfg, ok := remapConfigKey(a.Config, fromKey, toKey, lookup); ok {
|
|
a = GenericAction{Type: a.Type, Config: cfg}
|
|
}
|
|
}
|
|
result = append(result, a)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// remapConfigKey copies config, replaces fromKey with toKey using lookup.
|
|
func remapConfigKey(config map[string]any, fromKey, toKey string, lookup func(any) (any, bool)) (map[string]any, bool) {
|
|
if config == nil {
|
|
return nil, false
|
|
}
|
|
val, exists := config[fromKey]
|
|
if !exists {
|
|
return nil, false
|
|
}
|
|
newVal, found := lookup(val)
|
|
if !found {
|
|
return nil, false
|
|
}
|
|
cfg := make(map[string]any, len(config))
|
|
maps.Copy(cfg, config)
|
|
delete(cfg, fromKey)
|
|
cfg[toKey] = newVal
|
|
return cfg, true
|
|
}
|
|
|
|
func toInt(v any) (int, bool) {
|
|
switch val := v.(type) {
|
|
case float64:
|
|
if math.IsNaN(val) || math.IsInf(val, 0) || math.Trunc(val) != val {
|
|
return 0, false
|
|
}
|
|
return int(val), true
|
|
case int:
|
|
return val, true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|