Files
kandev/apps/backend/internal/orchestrator/workflow_callbacks.go
T

189 lines
6.8 KiB
Go

package orchestrator
import (
"context"
"fmt"
"github.com/kandev/kandev/internal/workflow/engine"
)
// buildWorkflowCallbacks creates the callback registry for the workflow engine.
// Each callback wraps an existing orchestrator Service method, keeping side-effect
// logic in the orchestrator while letting the engine drive evaluation.
//
// Phase 2 (ADR-0004) callbacks — queue_run, clear_decisions,
// queue_run_for_each_participant — are registered conditionally based on
// the orchestrator's wired adapters. If adapters are missing the action
// kinds simply have no callback; the engine treats unknown kinds as no-ops
// (see engine.executeCallback). This keeps kanban-only deployments
// untouched.
func buildWorkflowCallbacks(svc *Service) engine.MapRegistry {
r := engine.MapRegistry{
engine.ActionEnablePlanMode: &enablePlanModeCallback{svc: svc},
engine.ActionDisablePlanMode: &disablePlanModeCallback{svc: svc},
engine.ActionResetAgentContext: &resetAgentContextCallback{svc: svc},
engine.ActionAutoStartAgent: &autoStartAgentCallback{svc: svc},
engine.ActionSetWorkflowData: &setWorkflowDataCallback{},
engine.ActionSetSessionMode: &setSessionModeCallback{svc: svc},
}
if svc.engineRunQueue != nil {
r[engine.ActionQueueRun] = engine.QueueRunCallback{
Adapter: svc.engineRunQueue,
Participants: svc.engineParticipants,
CEOResolver: svc.engineCEOResolver,
Primary: svc.enginePrimary,
}
if svc.engineParticipants != nil {
r[engine.ActionQueueRunForEachParticipant] = engine.QueueRunForEachParticipantCallback{
Adapter: svc.engineRunQueue,
Participants: svc.engineParticipants,
}
}
}
if svc.engineDecisions != nil {
r[engine.ActionClearDecisions] = engine.ClearDecisionsCallback{Decisions: svc.engineDecisions}
}
if svc.engineTaskCreator != nil {
r[engine.ActionCreateChildTask] = engine.CreateChildTaskCallback{Creator: svc.engineTaskCreator}
}
if svc.engineWorkflowSwitcher != nil {
r[engine.ActionSwitchWorkflow] = engine.SwitchWorkflowCallback{
Switcher: svc.engineWorkflowSwitcher,
Dispatch: switchWorkflowDispatcher(svc),
}
}
return r
}
// switchWorkflowDispatcher returns the closure SwitchWorkflowCallback uses
// to fire on_exit / on_enter. It reads svc.workflowEngine lazily — at
// registration time the engine may not yet be initialised, but it is
// guaranteed by the time the closure runs (callbacks only execute after
// HandleTrigger).
func switchWorkflowDispatcher(svc *Service) engine.DispatchTriggerFn {
return func(ctx context.Context, taskID, sessionID string, trigger engine.Trigger, operationID string) error {
eng := svc.workflowEngine
if eng == nil {
return nil // engine not initialised; treat as no-op
}
_, err := eng.HandleTrigger(ctx, engine.HandleInput{
TaskID: taskID,
SessionID: sessionID,
Trigger: trigger,
OperationID: operationID,
})
return err
}
}
// enablePlanModeCallback enables plan mode on the session.
type enablePlanModeCallback struct {
svc *Service
}
func (c *enablePlanModeCallback) Execute(ctx context.Context, in engine.ActionInput) (engine.ActionResult, error) {
if in.State.IsPassthrough {
return engine.ActionResult{}, nil
}
session, err := c.svc.repo.GetTaskSession(ctx, in.State.SessionID)
if err != nil {
return engine.ActionResult{}, fmt.Errorf("load session for enable plan mode: %w", err)
}
c.svc.setSessionPlanMode(ctx, session, true)
return engine.ActionResult{}, nil
}
// disablePlanModeCallback disables plan mode on the session.
type disablePlanModeCallback struct {
svc *Service
}
func (c *disablePlanModeCallback) Execute(ctx context.Context, in engine.ActionInput) (engine.ActionResult, error) {
if in.State.IsPassthrough {
return engine.ActionResult{}, nil
}
session, err := c.svc.repo.GetTaskSession(ctx, in.State.SessionID)
if err != nil {
return engine.ActionResult{}, fmt.Errorf("load session for disable plan mode: %w", err)
}
c.svc.clearSessionPlanMode(ctx, session)
return engine.ActionResult{}, nil
}
// setSessionModeCallback applies a workflow-declared session permission mode
// (e.g. "acceptEdits") when entering a step. See issue #1183.
type setSessionModeCallback struct {
svc *Service
}
func (c *setSessionModeCallback) Execute(ctx context.Context, in engine.ActionInput) (engine.ActionResult, error) {
// Skip before any DB lookup: passthrough sessions manage their own mode in
// the CLI, and an action with no mode is a no-op. Guarding here keeps a
// skipped action from failing on a session-load error, and mirrors the
// enable/disable plan-mode callbacks.
if in.Action.SetSessionMode == nil || in.State.IsPassthrough {
return engine.ActionResult{}, nil
}
session, err := c.svc.repo.GetTaskSession(ctx, in.State.SessionID)
if err != nil {
return engine.ActionResult{}, fmt.Errorf("load session for set session mode: %w", err)
}
// Passthrough is already excluded above, so pass false explicitly; the
// isPassthrough parameter exists for the legacy processOnEnter call site.
c.svc.applyStepSessionMode(ctx, session, in.Action.SetSessionMode.Mode, false)
return engine.ActionResult{}, nil
}
// resetAgentContextCallback restarts the agent subprocess with a fresh ACP session.
type resetAgentContextCallback struct {
svc *Service
}
func (c *resetAgentContextCallback) Execute(ctx context.Context, in engine.ActionInput) (engine.ActionResult, error) {
session, err := c.svc.repo.GetTaskSession(ctx, in.State.SessionID)
if err != nil {
return engine.ActionResult{}, fmt.Errorf("load session for reset agent context: %w", err)
}
ok := c.svc.resetAgentContext(ctx, in.State.TaskID, session, in.Step.Name)
if !ok {
return engine.ActionResult{}, fmt.Errorf("failed to reset agent context for session %s", in.State.SessionID)
}
return engine.ActionResult{}, nil
}
// autoStartAgentCallback sends the auto-start prompt for a workflow step.
type autoStartAgentCallback struct {
svc *Service
}
func (c *autoStartAgentCallback) Execute(ctx context.Context, in engine.ActionInput) (engine.ActionResult, error) {
if in.State.IsPassthrough {
return engine.ActionResult{}, nil
}
_, err := c.svc.LaunchSession(ctx, &LaunchSessionRequest{
TaskID: in.State.TaskID,
Intent: IntentWorkflowStep,
SessionID: in.State.SessionID,
WorkflowStepID: in.Step.ID,
})
if err != nil {
return engine.ActionResult{}, fmt.Errorf("auto-start via LaunchSession failed: %w", err)
}
return engine.ActionResult{}, nil
}
// setWorkflowDataCallback writes key/value data into the workflow data bag.
type setWorkflowDataCallback struct{}
func (c *setWorkflowDataCallback) Execute(_ context.Context, in engine.ActionInput) (engine.ActionResult, error) {
if in.Action.SetWorkflowData == nil {
return engine.ActionResult{}, nil
}
return engine.ActionResult{
DataPatch: map[string]any{
in.Action.SetWorkflowData.Key: in.Action.SetWorkflowData.Value,
},
}, nil
}