2d73a3d1db
* fix: keep completed office comments editable Keep the office task detail composer editable after completed sessions load, including projectless office tasks. Update verification tests for SHA-pinned GitHub actions. Extract the dockview main area to satisfy the existing max-lines lint rule. * fix: preserve upload artifact version guard * fix: clarify upload artifact pin check * fix: wake completed office sessions on comments * fix: queue completed office comment runs * fix: preserve dispatcher session lookup errors * fix: resolve office comment wake review issues * fix: stabilize comment wake idempotency * fix: salt custom comment wake keys * fix: address comment wake review cleanup * fix: avoid comment payload allocation overflow pattern * fix: salt comment wake action variants * fix: resolve cross-task primary by target step * fix: resolve cross-task participant roles * fix: surface cross-task comment run status * fix: use target steps for cross-task queue runs * fix: avoid comment run lookup scan regression * fix: cover cross-task queue run edge cases * fix: index comment payload run lookup * fix: support postgres comment run index * fix: tighten queue run target test coverage * fix: route source comment run updates * fix: preserve cross-task comment wakes * fix: tighten comment wake identity * fix: align comment wake routing * fix: remove stale primary resolver * fix: dedupe comment run status * fix: tighten legacy comment wake dedupe * fix: remove unreachable comment wake guard * fix: skip duplicate comment reactivity wakes * fix: route comment wakes synchronously * fix: share comment engine dispatch contracts * fix: preserve comment wake fallbacks * fix: preserve done comment runner * fix: guard empty task runner fallback * fix: handle empty task runner fallback * fix: stabilize completed comment wakes * fix: index comment runner fallback --------- Co-authored-by: root <root@kandev.ts.net>
354 lines
11 KiB
Go
354 lines
11 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"maps"
|
|
)
|
|
|
|
// MachineState captures runtime workflow state for a task session.
|
|
type MachineState struct {
|
|
TaskID string
|
|
SessionID string
|
|
WorkflowID string
|
|
CurrentStepID string
|
|
SessionState string
|
|
TaskDescription string
|
|
IsPassthrough bool
|
|
Data map[string]any
|
|
}
|
|
|
|
// ActionInput is provided to action callbacks.
|
|
type ActionInput struct {
|
|
Trigger Trigger
|
|
State MachineState
|
|
Step StepSpec
|
|
Action Action
|
|
// Payload is the typed trigger payload (one of *Payload structs in
|
|
// payloads.go). Nil for kanban-era triggers; callbacks for the new
|
|
// Phase 2 triggers type-assert to the matching struct.
|
|
Payload any
|
|
// OperationID is the engine's idempotency key for this trigger
|
|
// invocation. Callbacks that themselves need a deterministic
|
|
// idempotency key (e.g. queue_run) should derive theirs from this
|
|
// value combined with action-specific salt.
|
|
OperationID string
|
|
}
|
|
|
|
// ActionResult communicates side effects back to the engine.
|
|
type ActionResult struct {
|
|
DataPatch map[string]any
|
|
}
|
|
|
|
// ActionCallback executes side-effect actions.
|
|
type ActionCallback interface {
|
|
Execute(ctx context.Context, in ActionInput) (ActionResult, error)
|
|
}
|
|
|
|
// CallbackRegistry resolves callbacks for action kinds.
|
|
type CallbackRegistry interface {
|
|
Get(kind ActionKind) (ActionCallback, bool)
|
|
}
|
|
|
|
// TransitionStore abstracts persistence and transition commits.
|
|
type TransitionStore interface {
|
|
LoadState(ctx context.Context, taskID, sessionID string) (MachineState, error)
|
|
LoadStep(ctx context.Context, workflowID, stepID string) (StepSpec, error)
|
|
LoadNextStep(ctx context.Context, workflowID string, currentPosition int) (StepSpec, error)
|
|
LoadPreviousStep(ctx context.Context, workflowID string, currentPosition int) (StepSpec, error)
|
|
ApplyTransition(ctx context.Context, taskID, sessionID, fromStepID, toStepID string, trigger Trigger) error
|
|
PersistData(ctx context.Context, sessionID string, data map[string]any) error
|
|
IsOperationApplied(ctx context.Context, operationID string) (bool, error)
|
|
MarkOperationApplied(ctx context.Context, operationID string) error
|
|
}
|
|
|
|
// HandleInput is the input envelope for handling a workflow trigger.
|
|
type HandleInput struct {
|
|
TaskID string
|
|
SessionID string
|
|
Trigger Trigger
|
|
OperationID string
|
|
EvaluateOnly bool // when true, skip ApplyTransition and PersistData; caller handles persistence
|
|
|
|
// PreloadedState, when set, skips the LoadState call in the engine.
|
|
// Use this to avoid redundant DB reads when the caller already loaded the session and task.
|
|
PreloadedState *MachineState
|
|
|
|
// Payload is the typed trigger payload — nil for kanban-era triggers.
|
|
Payload any
|
|
}
|
|
|
|
// HandleResult summarizes engine work for a trigger.
|
|
type HandleResult struct {
|
|
Transitioned bool
|
|
FromStepID string
|
|
ToStepID string
|
|
DataPatch map[string]any
|
|
Idempotent bool
|
|
ActionCount int
|
|
}
|
|
|
|
// Option configures an Engine at construction time. Use With* helpers below.
|
|
type Option func(*Engine)
|
|
|
|
// WithRunQueue wires the engine's RunQueueAdapter for queue_run actions.
|
|
// When unset, queue_run callbacks return an error — kanban workflows that
|
|
// never use queue_run are unaffected.
|
|
func WithRunQueue(adapter RunQueueAdapter) Option {
|
|
return func(e *Engine) { e.runQueue = adapter }
|
|
}
|
|
|
|
// WithParticipantStore wires read access to step participants.
|
|
func WithParticipantStore(store ParticipantStore) Option {
|
|
return func(e *Engine) { e.participants = store }
|
|
}
|
|
|
|
// WithDecisionStore wires read/write access to step decisions.
|
|
func WithDecisionStore(store DecisionStore) Option {
|
|
return func(e *Engine) { e.decisions = store }
|
|
}
|
|
|
|
// WithCEOAgentResolver wires resolution for the "workspace.ceo_agent"
|
|
// QueueRun target.
|
|
func WithCEOAgentResolver(resolver CEOAgentResolver) Option {
|
|
return func(e *Engine) { e.ceoResolver = resolver }
|
|
}
|
|
|
|
// WithTaskCreator wires the engine's TaskCreator for the create_child_task
|
|
// action. When unset, create_child_task callbacks return ErrActionNotYetWired.
|
|
func WithTaskCreator(creator TaskCreator) Option {
|
|
return func(e *Engine) { e.taskCreator = creator }
|
|
}
|
|
|
|
// WithWorkflowSwitcher wires the engine's WorkflowSwitcher for the
|
|
// switch_workflow action. When unset, switch_workflow callbacks return
|
|
// ErrActionNotYetWired.
|
|
func WithWorkflowSwitcher(switcher WorkflowSwitcher) Option {
|
|
return func(e *Engine) { e.workflowSwitcher = switcher }
|
|
}
|
|
|
|
// Engine evaluates step actions and applies transitions.
|
|
type Engine struct {
|
|
store TransitionStore
|
|
callbacks CallbackRegistry
|
|
|
|
// Phase 2 (ADR-0004) dependencies — nil-safe: an Engine wired only with
|
|
// store + callbacks behaves identically to today's kanban engine.
|
|
runQueue RunQueueAdapter
|
|
participants ParticipantStore
|
|
decisions DecisionStore
|
|
ceoResolver CEOAgentResolver
|
|
// Phase 8 (ADR-0004) dependencies — also nil-safe.
|
|
taskCreator TaskCreator
|
|
workflowSwitcher WorkflowSwitcher
|
|
}
|
|
|
|
// TaskCreatorAdapter exposes the wired TaskCreator (or nil if unset).
|
|
// Used by callbacks that resolve TaskCreator at registration time.
|
|
func (e *Engine) TaskCreatorAdapter() TaskCreator { return e.taskCreator }
|
|
|
|
// WorkflowSwitcherAdapter exposes the wired WorkflowSwitcher (or nil if
|
|
// unset). Used by callbacks that resolve the switcher at registration
|
|
// time.
|
|
func (e *Engine) WorkflowSwitcherAdapter() WorkflowSwitcher { return e.workflowSwitcher }
|
|
|
|
// New creates a workflow engine. Phase 2 dependencies (RunQueueAdapter,
|
|
// ParticipantStore, DecisionStore, CEOAgentResolver) are wired via Option.
|
|
// Without them, the engine still serves today's kanban workflows: queue_run
|
|
// and friends are inert because no kanban step references them.
|
|
func New(store TransitionStore, callbacks CallbackRegistry, opts ...Option) *Engine {
|
|
e := &Engine{store: store, callbacks: callbacks}
|
|
for _, opt := range opts {
|
|
opt(e)
|
|
}
|
|
return e
|
|
}
|
|
|
|
// HandleTrigger executes actions for the provided trigger.
|
|
func (e *Engine) HandleTrigger(ctx context.Context, in HandleInput) (HandleResult, error) {
|
|
if in.TaskID == "" || in.SessionID == "" {
|
|
return HandleResult{}, fmt.Errorf("task_id and session_id are required")
|
|
}
|
|
idempotent, err := e.isOperationAlreadyApplied(ctx, in.OperationID)
|
|
if err != nil {
|
|
return HandleResult{}, err
|
|
}
|
|
if idempotent {
|
|
return HandleResult{Idempotent: true}, nil
|
|
}
|
|
|
|
state, step, err := e.loadExecutionContext(ctx, in)
|
|
if err != nil {
|
|
return HandleResult{}, err
|
|
}
|
|
|
|
actions := step.Events[in.Trigger]
|
|
if len(actions) == 0 {
|
|
return HandleResult{}, e.markOperationApplied(ctx, in.OperationID)
|
|
}
|
|
|
|
result, err := e.processActions(ctx, in, state, step, actions)
|
|
if err != nil {
|
|
return HandleResult{}, err
|
|
}
|
|
|
|
return result, e.markOperationApplied(ctx, in.OperationID)
|
|
}
|
|
|
|
// processActions evaluates actions, persists data, and applies transitions.
|
|
func (e *Engine) processActions(
|
|
ctx context.Context,
|
|
in HandleInput,
|
|
state MachineState,
|
|
step StepSpec,
|
|
actions []Action,
|
|
) (HandleResult, error) {
|
|
targetStepID, dataPatch, err := e.evaluateActions(ctx, in, state, step, actions)
|
|
if err != nil {
|
|
return HandleResult{}, err
|
|
}
|
|
|
|
if len(dataPatch) > 0 && !in.EvaluateOnly {
|
|
if err := e.store.PersistData(ctx, in.SessionID, dataPatch); err != nil {
|
|
return HandleResult{}, err
|
|
}
|
|
}
|
|
|
|
result := HandleResult{DataPatch: dataPatch, ActionCount: len(actions)}
|
|
if targetStepID != "" && targetStepID != state.CurrentStepID {
|
|
if !in.EvaluateOnly {
|
|
if err := e.applyTransition(ctx, in, state, targetStepID); err != nil {
|
|
return HandleResult{}, err
|
|
}
|
|
}
|
|
result.Transitioned = true
|
|
result.FromStepID = state.CurrentStepID
|
|
result.ToStepID = targetStepID
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (e *Engine) isOperationAlreadyApplied(ctx context.Context, operationID string) (bool, error) {
|
|
if operationID == "" {
|
|
return false, nil
|
|
}
|
|
return e.store.IsOperationApplied(ctx, operationID)
|
|
}
|
|
|
|
func (e *Engine) markOperationApplied(ctx context.Context, operationID string) error {
|
|
if operationID == "" {
|
|
return nil
|
|
}
|
|
return e.store.MarkOperationApplied(ctx, operationID)
|
|
}
|
|
|
|
func (e *Engine) loadExecutionContext(ctx context.Context, in HandleInput) (MachineState, StepSpec, error) {
|
|
var state MachineState
|
|
if in.PreloadedState != nil {
|
|
state = *in.PreloadedState
|
|
} else {
|
|
var err error
|
|
state, err = e.store.LoadState(ctx, in.TaskID, in.SessionID)
|
|
if err != nil {
|
|
return MachineState{}, StepSpec{}, err
|
|
}
|
|
}
|
|
step, err := e.store.LoadStep(ctx, state.WorkflowID, state.CurrentStepID)
|
|
if err != nil {
|
|
return MachineState{}, StepSpec{}, err
|
|
}
|
|
return state, step, nil
|
|
}
|
|
|
|
func (e *Engine) evaluateActions(
|
|
ctx context.Context,
|
|
in HandleInput,
|
|
state MachineState,
|
|
step StepSpec,
|
|
actions []Action,
|
|
) (string, map[string]any, error) {
|
|
var targetStepID string
|
|
dataPatch := map[string]any{}
|
|
for _, action := range actions {
|
|
if targetStepID == "" && isTransitionAction(action.Kind) && !action.RequiresApproval {
|
|
permitted, err := e.evaluateTransitionGuard(ctx, state, action)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
if !permitted {
|
|
continue
|
|
}
|
|
resolvedTarget, err := e.resolveTransitionTarget(ctx, state, step, action)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
targetStepID = resolvedTarget
|
|
continue
|
|
}
|
|
if err := e.executeCallback(ctx, in, state, step, action, dataPatch); err != nil {
|
|
return "", nil, err
|
|
}
|
|
}
|
|
return targetStepID, dataPatch, nil
|
|
}
|
|
|
|
func (e *Engine) applyTransition(ctx context.Context, in HandleInput, state MachineState, targetStepID string) error {
|
|
return e.store.ApplyTransition(ctx, in.TaskID, in.SessionID, state.CurrentStepID, targetStepID, in.Trigger)
|
|
}
|
|
|
|
func (e *Engine) executeCallback(
|
|
ctx context.Context,
|
|
in HandleInput,
|
|
state MachineState,
|
|
step StepSpec,
|
|
action Action,
|
|
dataPatch map[string]any,
|
|
) error {
|
|
callback, ok := e.callbacks.Get(action.Kind)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
res, err := callback.Execute(ctx, ActionInput{
|
|
Trigger: in.Trigger,
|
|
State: state,
|
|
Step: step,
|
|
Action: action,
|
|
Payload: in.Payload,
|
|
OperationID: in.OperationID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
maps.Copy(dataPatch, res.DataPatch)
|
|
return nil
|
|
}
|
|
|
|
func (e *Engine) resolveTransitionTarget(ctx context.Context, state MachineState, step StepSpec, action Action) (string, error) {
|
|
switch action.Kind {
|
|
case ActionMoveToNext:
|
|
next, err := e.store.LoadNextStep(ctx, state.WorkflowID, step.Position)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return next.ID, nil
|
|
case ActionMoveToPrevious:
|
|
prev, err := e.store.LoadPreviousStep(ctx, state.WorkflowID, step.Position)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return prev.ID, nil
|
|
case ActionMoveToStep:
|
|
if action.MoveToStep == nil || action.MoveToStep.StepID == "" {
|
|
return "", fmt.Errorf("move_to_step missing target step_id")
|
|
}
|
|
return action.MoveToStep.StepID, nil
|
|
default:
|
|
return "", nil
|
|
}
|
|
}
|
|
|
|
func isTransitionAction(kind ActionKind) bool {
|
|
return kind == ActionMoveToNext || kind == ActionMoveToPrevious || kind == ActionMoveToStep
|
|
}
|