195530e576
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
154 lines
6.2 KiB
Go
154 lines
6.2 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/kandev/kandev/internal/steptelemetry"
|
|
)
|
|
|
|
// CreateChildTaskCallback executes the create_child_task action by asking
|
|
// the wired TaskCreator to create a new task whose parent is the trigger's
|
|
// task id.
|
|
//
|
|
// The new task's on_enter trigger fires naturally once the row lands —
|
|
// the workflow engine evaluates it the first time the child agent starts
|
|
// running. This callback is fire-and-forget on success.
|
|
type CreateChildTaskCallback struct {
|
|
Creator TaskCreator
|
|
}
|
|
|
|
// Execute satisfies ActionCallback.
|
|
func (c CreateChildTaskCallback) Execute(ctx context.Context, in ActionInput) (ActionResult, error) {
|
|
if c.Creator == nil {
|
|
return ActionResult{}, fmt.Errorf("%w: create_child_task requires TaskCreator", ErrActionNotYetWired)
|
|
}
|
|
if in.Action.CreateChildTask == nil {
|
|
return ActionResult{}, fmt.Errorf("create_child_task action missing CreateChildTask config")
|
|
}
|
|
if in.State.TaskID == "" {
|
|
return ActionResult{}, fmt.Errorf("create_child_task: trigger has no task id")
|
|
}
|
|
cfg := in.Action.CreateChildTask
|
|
if cfg.Title == "" {
|
|
return ActionResult{}, fmt.Errorf("create_child_task: title is required")
|
|
}
|
|
spec := ChildTaskSpec{
|
|
Title: cfg.Title,
|
|
Description: cfg.Description,
|
|
WorkflowID: cfg.WorkflowID,
|
|
StepID: cfg.StepID,
|
|
AgentProfileID: cfg.AgentProfileID,
|
|
CausingAgentProfileID: in.State.AgentProfileID,
|
|
}
|
|
// The new child task's genesis ledger row must attribute the trigger's
|
|
// session when one exists — create_child_task typically fires from a
|
|
// session's own turn (on_turn_complete evaluating the step's actions),
|
|
// so leaving ctx unwrapped would let genesisAttribution fall through to
|
|
// the (session-less) authn seam and silently record actor_kind=system
|
|
// for a session-caused creation, the same bug already fixed for
|
|
// switch_workflow in SwitchWorkflowCallback.Execute. Unlike that
|
|
// callback, create_child_task doesn't require a session up front — a
|
|
// future non-session-originated trigger falls back to genesis's
|
|
// existing default rather than fabricating one.
|
|
createCtx := ctx
|
|
if in.State.SessionID != "" {
|
|
createCtx = steptelemetry.WithAttribution(ctx, steptelemetry.Attribution{
|
|
ActorKind: steptelemetry.ActorAgent,
|
|
ActorID: in.State.SessionID,
|
|
SessionID: in.State.SessionID,
|
|
})
|
|
}
|
|
if _, err := c.Creator.CreateChildTask(createCtx, in.State.TaskID, spec); err != nil {
|
|
return ActionResult{}, fmt.Errorf("create_child_task: %w", err)
|
|
}
|
|
return ActionResult{}, nil
|
|
}
|
|
|
|
// SwitchWorkflowCallback executes the switch_workflow action by asking the
|
|
// wired WorkflowSwitcher to mutate the task's workflow_id /
|
|
// workflow_step_id and then dispatching on_exit on the old step + on_enter
|
|
// on the new step via the supplied DispatchTrigger.
|
|
//
|
|
// The dispatcher is the same engine.HandleTrigger used elsewhere; we pass
|
|
// it explicitly (rather than calling Engine.HandleTrigger directly) to
|
|
// avoid a circular dependency between Engine and its callbacks.
|
|
type SwitchWorkflowCallback struct {
|
|
Switcher WorkflowSwitcher
|
|
Dispatch DispatchTriggerFn
|
|
}
|
|
|
|
// DispatchTriggerFn is the closure SwitchWorkflowCallback uses to fire
|
|
// on_exit / on_enter on the supplied (taskID, sessionID, trigger). The source
|
|
// step ID is carried explicitly because on_enter runs after the task row has
|
|
// moved to the destination step, while source-owned session retirement still
|
|
// has to use the step being left.
|
|
//
|
|
// The closure is responsible for building HandleInput; in production it
|
|
// wraps Engine.HandleTrigger.
|
|
type DispatchTriggerFn func(ctx context.Context, taskID, sessionID string, trigger Trigger, operationID, sourceStepID string) error
|
|
|
|
// Execute satisfies ActionCallback.
|
|
func (c SwitchWorkflowCallback) Execute(ctx context.Context, in ActionInput) (ActionResult, error) {
|
|
if c.Switcher == nil {
|
|
return ActionResult{}, fmt.Errorf("%w: switch_workflow requires WorkflowSwitcher", ErrActionNotYetWired)
|
|
}
|
|
if in.Action.SwitchWorkflow == nil {
|
|
return ActionResult{}, fmt.Errorf("switch_workflow action missing SwitchWorkflow config")
|
|
}
|
|
cfg := in.Action.SwitchWorkflow
|
|
if cfg.WorkflowID == "" {
|
|
return ActionResult{}, fmt.Errorf("switch_workflow: workflow_id is required")
|
|
}
|
|
if in.State.TaskID == "" || in.State.SessionID == "" {
|
|
return ActionResult{}, fmt.Errorf("switch_workflow: trigger has no task/session id")
|
|
}
|
|
|
|
// Fire on_exit on the old step first so any cleanup (e.g. plan-mode
|
|
// disable) runs before we swap the workflow row. Skip dispatch in
|
|
// EvaluateOnly mode — the engine itself handles on_exit/on_enter
|
|
// orchestration when the caller drives evaluation.
|
|
if c.Dispatch != nil {
|
|
exitOpID := fmt.Sprintf("%s:switch_workflow:on_exit", in.OperationID)
|
|
if err := c.Dispatch(ctx, in.State.TaskID, in.State.SessionID, TriggerOnExit, exitOpID, workflowSwitchSourceStepID(in)); err != nil {
|
|
return ActionResult{}, fmt.Errorf("switch_workflow on_exit: %w", err)
|
|
}
|
|
}
|
|
|
|
// A switch_workflow action is always genuinely caused by the trigger's
|
|
// session — validated above, since in.State.SessionID must be non-empty
|
|
// to reach this point — so the ledger row this writes must attribute
|
|
// that session, not fall back to the (session-less) authn seam.
|
|
switchCtx := steptelemetry.WithAttribution(ctx, steptelemetry.Attribution{
|
|
Trigger: steptelemetry.TriggerWorkflowAttached,
|
|
ActorKind: steptelemetry.ActorAgent,
|
|
ActorID: in.State.SessionID,
|
|
SessionID: in.State.SessionID,
|
|
})
|
|
if _, err := c.Switcher.SwitchTaskWorkflow(switchCtx, in.State.TaskID, cfg.WorkflowID, cfg.StepID); err != nil {
|
|
return ActionResult{}, fmt.Errorf("switch_workflow: %w", err)
|
|
}
|
|
|
|
// Fire on_enter on the new step.
|
|
if c.Dispatch != nil {
|
|
enterOpID := fmt.Sprintf("%s:switch_workflow:on_enter", in.OperationID)
|
|
if err := c.Dispatch(ctx, in.State.TaskID, in.State.SessionID, TriggerOnEnter, enterOpID, workflowSwitchSourceStepID(in)); err != nil {
|
|
return ActionResult{}, fmt.Errorf("switch_workflow on_enter: %w", err)
|
|
}
|
|
}
|
|
return ActionResult{}, nil
|
|
}
|
|
|
|
func workflowSwitchSourceStepID(in ActionInput) string {
|
|
if in.State.CurrentStepID != "" {
|
|
return in.State.CurrentStepID
|
|
}
|
|
return in.Step.ID
|
|
}
|
|
|
|
// Compile-time interface assertions.
|
|
var (
|
|
_ ActionCallback = CreateChildTaskCallback{}
|
|
_ ActionCallback = SwitchWorkflowCallback{}
|
|
)
|