Files
kandev/apps/backend/internal/workflow/engine/entrydispatch.go
T
2026-08-26 16:00:35 +00:00

142 lines
5.6 KiB
Go

package engine
import (
"context"
"strings"
)
// sessionShapedActionKinds are 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).
var sessionShapedActionKinds = map[ActionKind]bool{
ActionEnablePlanMode: true,
ActionAutoStartAgent: true,
ActionResetAgentContext: true,
ActionSetSessionMode: true,
}
// sessionIndependentActionKinds are 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).
var sessionIndependentActionKinds = map[ActionKind]bool{
ActionClearDecisions: true,
ActionQueueRunForEachParticipant: true,
ActionQueueRun: true,
ActionRunCodeReview: true,
ActionEnsureParticipantSeat: true,
}
func isSessionShapedActionKind(kind ActionKind) bool {
return sessionShapedActionKinds[kind]
}
func isSessionIndependentActionKind(kind ActionKind) bool {
return sessionIndependentActionKinds[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").
func (e *Engine) DispatchStepEntry(ctx context.Context, taskID, workflowID, stepID, entryID string) []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
}
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
}