Files
kandev/apps/backend/internal/workflow/engine/entrydispatch.go
T
2026-09-13 10:23:03 +01:00

155 lines
6.7 KiB
Go

package engine
import (
"context"
"strings"
"github.com/kandev/kandev/internal/workflow/stepentry"
)
// isSessionShapedActionKind reports whether kind is one of the step-entry
// action kinds a route with a live arriving session already executes today —
// through the orchestrator's own inline entry handler on most routes, and
// through HandleTriggerSessionShapedOnly on the workflow-switch route.
// DispatchStepEntry never runs these: running them a second time would
// launch or prompt an agent twice (AC-OFFICE-STEP-ENTRY-001.3, .5). Reads
// the single ownership declaration (AC-OFFICE-STEP-ENTRY-DISPATCH-002.1)
// instead of keeping a private list.
func isSessionShapedActionKind(kind ActionKind) bool {
return stepentry.OwnedByMarker(string(kind))
}
// isSessionIndependentActionKind reports whether kind is one of the
// step-entry action kinds DispatchStepEntry executes. None of them reads or
// mutates the arriving session, so every one is safe to run for an arrival
// with no live session (AC-OFFICE-STEP-ENTRY-001.1). Reads the single
// ownership declaration instead of keeping a private list.
func isSessionIndependentActionKind(kind ActionKind) bool {
return stepentry.OwnedByLedger(string(kind))
}
// StepEntryActionResult records the outcome of one action DispatchStepEntry
// attempted. Err is nil for an action that succeeded.
type StepEntryActionResult struct {
Kind ActionKind
Err error
}
// DispatchStepEntry executes the session-independent half of the step's
// declared on_enter sequence for one committed arrival at (workflowID,
// stepID). Callers are the registered step-transition ledger writers, once
// per committed row that names a destination step, synchronously after
// their own commit (AC-OFFICE-STEP-ENTRY-001.1, .8, .9).
//
// Unlike HandleTrigger, this requires no live session, uses a
// record-and-continue loop instead of aborting on the first failed action
// (AC-OFFICE-STEP-ENTRY-001.4, .6), and carries entryID — the ledger row's
// own identifier — rather than an operation id
// (AC-OFFICE-STEP-ENTRY-001.2, .7). It bypasses HandleTrigger's
// already-applied/mark-applied bookkeeping entirely: per design, there is no
// separate "this entry already ran" marker. Each dispatched action is
// individually idempotent against durable state keyed by entryID
// (AC-OFFICE-STEP-ENTRY-001.10) — see
// docs/specs/office/system-design/step-entry-sequence-execution.md
// ("Where 'this entry already ran' is recorded").
//
// markerEntryID is the workflow_step_entries row allocated for this arrival
// (0 when none was allocated). A marker-bearing action (clear_decisions,
// queue_run_for_each_participant — stepentry.MarkerBearing) is routed through
// markerExecutor when both it and markerEntryID are set, claiming its marker
// before executing so a redelivered arrival is a safe no-op
// (AC-OFFICE-STEP-ENTRY-DISPATCH-002.3/.4). Per AC-002.10, a marker-bearing
// action that fails or loses its claim abandons the remainder of this
// entry's sequence — later positions must not run against state an earlier,
// unfinished marker-bearing action never actually produced (e.g. the fan-out
// after a clear_decisions that never committed). Every other ledger-owned
// kind keeps the pre-convergence record-and-continue behaviour.
func (e *Engine) DispatchStepEntry(ctx context.Context, taskID, workflowID, stepID, entryID string, markerEntryID int64) []StepEntryActionResult {
step, err := e.store.LoadStep(ctx, workflowID, stepID)
if err != nil {
return []StepEntryActionResult{{Err: err}}
}
actions := step.Events[TriggerOnEnter]
if len(actions) == 0 {
return nil
}
state := MachineState{TaskID: taskID, WorkflowID: workflowID, CurrentStepID: stepID}
results := make([]StepEntryActionResult, 0, len(actions))
seenParticipantRoles := map[string]bool{}
for _, action := range actions {
if !isSessionIndependentActionKind(action.Kind) {
// Exclusion is the contract, not an anomaly: session-shaped
// kinds (and any kind not yet classified) are silently
// skipped here, not recorded as skipped work.
continue
}
if action.Kind == ActionEnsureParticipantSeat && seenEnsureParticipantSeatRole(seenParticipantRoles, action) {
// AC-OFFICE-REVIEW-SEATS-001.12/004.7: a step that declares the
// seat-ensuring action more than once for the same participant
// role writes at most one seat and emits at most one record for
// it. Re-running the callback for a repeat declaration would
// re-count an outcome (e.g. already_seated, or a second
// malformed-role warning) that AC-004.7 requires collapsed to
// one per role per entry.
continue
}
if e.markerExecutor != nil && markerEntryID != 0 && stepentry.MarkerBearing(string(action.Kind)) {
abandon, execErr := e.markerExecutor.ExecuteMarkerBearingStepEntryAction(ctx, taskID, step, action, action.DeclaredPosition, markerEntryID)
if abandon {
break
}
results = append(results, StepEntryActionResult{Kind: action.Kind, Err: execErr})
if execErr != nil {
break
}
continue
}
results = append(results, StepEntryActionResult{
Kind: action.Kind,
Err: e.executeStepEntryAction(ctx, state, step, action, entryID),
})
}
return results
}
// seenEnsureParticipantSeatRole reports whether action declares the same
// raw (unvalidated) participant role as an earlier ActionEnsureParticipantSeat
// action already dispatched in this DispatchStepEntry call, recording it if
// not. Deduping on the raw string — rather than only on validated roles —
// also collapses repeated malformed declarations (AC-OFFICE-REVIEW-SEATS-001.11)
// to a single warning record, matching AC-OFFICE-REVIEW-SEATS-004.7's "at
// most one record per participant role for each condition" for both the
// repeated-declaration and malformed-declaration conditions.
func seenEnsureParticipantSeatRole(seen map[string]bool, action Action) bool {
var role string
if action.EnsureParticipantSeat != nil {
role = strings.TrimSpace(action.EnsureParticipantSeat.Role)
}
if seen[role] {
return true
}
seen[role] = true
return false
}
// executeStepEntryAction runs one action's callback for DispatchStepEntry.
// An unregistered kind is a no-op, matching Engine.executeCallback. There is
// no DataPatch handling: none of the session-independent kinds ever produce
// one (only set_workflow_data does, and it is not compiled for step entry),
// and DispatchStepEntry has no session to persist one against.
func (e *Engine) executeStepEntryAction(ctx context.Context, state MachineState, step StepSpec, action Action, entryID string) error {
callback, ok := e.callbacks.Get(action.Kind)
if !ok {
return nil
}
_, err := callback.Execute(ctx, ActionInput{
Trigger: TriggerOnEnter,
State: state,
Step: step,
Action: action,
EntryID: entryID,
})
return err
}