ffa42a8cfd
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
1032 lines
40 KiB
Go
1032 lines
40 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
|
"github.com/kandev/kandev/internal/workflow/quorummetrics"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// ErrParticipantNotFound is returned by ResolveParticipantRole when the
|
|
// caller does not occupy a reviewer or approver seat with
|
|
// decision_required = 1 at the given step (AC-3).
|
|
var ErrParticipantNotFound = errors.New("workflow engine: participant not found")
|
|
|
|
// Threshold values understood by wait_for_quorum.
|
|
const (
|
|
QuorumAllApprove = "all_approve"
|
|
QuorumAllDecide = "all_decide"
|
|
QuorumAnyReject = "any_reject"
|
|
QuorumMajorityApprove = "majority_approve"
|
|
// QuorumNApprovePrefix matches thresholds of the form "n_approve:<N>".
|
|
QuorumNApprovePrefix = "n_approve:"
|
|
)
|
|
|
|
// Decision verdict strings the quorum evaluator recognises. Other free-form
|
|
// verdicts are accepted and stored, but only "approved" and "rejected" (and,
|
|
// per AC-9, "changes_requested" as a synonym for "rejected") are counted by
|
|
// the canned thresholds.
|
|
const (
|
|
DecisionApproved = "approved"
|
|
DecisionRejected = "rejected"
|
|
DecisionChangesRequested = "changes_requested"
|
|
)
|
|
|
|
// DeciderTypeAgent / DeciderTypeUser are the recognized decider_type values.
|
|
const (
|
|
DeciderTypeAgent = "agent"
|
|
DeciderTypeUser = "user"
|
|
)
|
|
|
|
// Reason codes for a guarded transition that does not fire. This is the
|
|
// closed set defined by AC-23; AC-24a's expvar keys are exactly these plus
|
|
// ReasonSessionUnresolvable (a recording-time skip, not a guard-evaluation
|
|
// outcome — see AC-52).
|
|
const (
|
|
ReasonThresholdNotMet = "threshold_not_met"
|
|
ReasonSlateEmpty = "slate_empty"
|
|
ReasonDecisionStoreUnwired = "decision_store_unwired"
|
|
ReasonParticipantStoreUnwired = "participant_store_unwired"
|
|
ReasonGuardVariantUnrecognized = "guard_variant_unrecognized"
|
|
ReasonThresholdUnrecognized = "threshold_unrecognized"
|
|
ReasonThresholdUnsatisfiable = "threshold_unsatisfiable"
|
|
ReasonEvaluationError = "evaluation_error"
|
|
ReasonSessionUnresolvable = "session_unresolvable"
|
|
)
|
|
|
|
// GuardOutcome is the result of evaluating one wait_for_quorum guard.
|
|
// Satisfied=true means the transition may fire; Reason is populated
|
|
// whenever Satisfied is false, with exactly one AC-23 code. Err carries the
|
|
// underlying error when Reason == ReasonEvaluationError.
|
|
type GuardOutcome struct {
|
|
Satisfied bool
|
|
Reason string
|
|
Err error
|
|
RequiredCount int
|
|
ReceivedCount int
|
|
}
|
|
|
|
// isRejectionVerdict reports whether a stored verdict counts as a rejection.
|
|
// AC-9: "changes_requested" is a synonym for "rejected" everywhere a
|
|
// rejection is counted.
|
|
func isRejectionVerdict(decision string) bool {
|
|
return decision == DecisionRejected || decision == DecisionChangesRequested
|
|
}
|
|
|
|
// evaluateTransitionGuard returns the guard outcome for an action. A nil
|
|
// guard always permits the transition (kanban semantics, unchanged). This is
|
|
// a thin wrapper over evaluateTransitionGuardRecording with record=true, for
|
|
// the committing HandleTrigger and reevaluation paths that must emit AC-004
|
|
// counters/logs; the AC-54/57d read-only diagnostic path calls the recording
|
|
// variant directly with record=false instead.
|
|
func (e *Engine) evaluateTransitionGuard(ctx context.Context, state MachineState, action Action) GuardOutcome {
|
|
return e.evaluateTransitionGuardRecording(ctx, state, action, true)
|
|
}
|
|
|
|
func (e *Engine) evaluateTransitionGuardRecording(ctx context.Context, state MachineState, action Action, record bool) GuardOutcome {
|
|
if action.Guard == nil {
|
|
return GuardOutcome{Satisfied: true}
|
|
}
|
|
if action.Guard.WaitForQuorum == nil {
|
|
// Unknown guard variant — fail closed (AC-23/AC-53's sibling code).
|
|
return GuardOutcome{Reason: ReasonGuardVariantUnrecognized}
|
|
}
|
|
return e.computeGuardOutcomeRecording(ctx, state, action.Guard.WaitForQuorum, record)
|
|
}
|
|
|
|
// recordGuardNotFired emits the AC-24/24a observability unit for a guarded
|
|
// transition that evaluated and did not fire: the quorummetrics counter
|
|
// (unconditional) and, when a logger is wired, a structured log record.
|
|
// Called from both the ordinary HandleTrigger/evaluateActions path and the
|
|
// AC-65-scoped decision re-evaluation path — the two "engine-path"
|
|
// evaluators AC-24 covers. The AC-24b/57d read-only diagnostic snapshot
|
|
// (EvaluateStepQuorum) must never call this.
|
|
func (e *Engine) recordGuardNotFired(state MachineState, action Action, outcome GuardOutcome) {
|
|
quorummetrics.RecordGuardNotFired(outcome.Reason)
|
|
if e.logger == nil {
|
|
return
|
|
}
|
|
fields := []zap.Field{
|
|
zap.String("task_id", state.TaskID),
|
|
zap.String("step_id", state.CurrentStepID),
|
|
zap.Int("required_count", outcome.RequiredCount),
|
|
zap.Int("received_count", outcome.ReceivedCount),
|
|
zap.String("reason", outcome.Reason),
|
|
}
|
|
if action.Guard != nil && action.Guard.WaitForQuorum != nil {
|
|
fields = append(fields,
|
|
zap.String("guard_role", action.Guard.WaitForQuorum.Role),
|
|
zap.String("guard_threshold", action.Guard.WaitForQuorum.Threshold),
|
|
)
|
|
}
|
|
if outcome.Err != nil {
|
|
fields = append(fields, zap.Error(outcome.Err))
|
|
}
|
|
e.logger.Warn("workflow quorum guard did not fire", fields...)
|
|
}
|
|
|
|
// recordReevaluationSkip emits the AC-24/24a observability unit for the
|
|
// AC-16a/F39 skip: a decision was recorded but re-evaluation could not run
|
|
// because the task's active session is unresolvable. Task-level only — no
|
|
// guard was evaluated, so guard-scoped fields (role, threshold, counts) are
|
|
// omitted.
|
|
func (e *Engine) recordReevaluationSkip(taskID, stepID string) {
|
|
quorummetrics.RecordGuardNotFired(ReasonSessionUnresolvable)
|
|
if e.logger == nil {
|
|
return
|
|
}
|
|
e.logger.Warn("workflow quorum reevaluation skipped: session unresolvable",
|
|
zap.String("task_id", taskID),
|
|
zap.String("step_id", stepID),
|
|
zap.String("reason", ReasonSessionUnresolvable),
|
|
)
|
|
}
|
|
|
|
// computeGuardOutcome evaluates a single wait_for_quorum guard against the
|
|
// engine's wired stores, following the AC-52 precedence order: store-unwired
|
|
// checks first, then (for approve-style thresholds) slate construction
|
|
// errors, then an empty slate, then threshold recognition, then
|
|
// unsatisfiability, then a genuinely unmet threshold. any_reject does not
|
|
// use the seat slate at all (AC-43/AC-59) and so skips the slate_empty step
|
|
// entirely.
|
|
func (e *Engine) computeGuardOutcome(ctx context.Context, state MachineState, guard *WaitForQuorumGuard) GuardOutcome {
|
|
return e.computeGuardOutcomeRecording(ctx, state, guard, true)
|
|
}
|
|
|
|
func (e *Engine) computeGuardOutcomeRecording(
|
|
ctx context.Context, state MachineState, guard *WaitForQuorumGuard, record bool,
|
|
) GuardOutcome {
|
|
if e.decisions == nil {
|
|
return GuardOutcome{Reason: ReasonDecisionStoreUnwired}
|
|
}
|
|
if e.participants == nil {
|
|
return GuardOutcome{Reason: ReasonParticipantStoreUnwired}
|
|
}
|
|
|
|
if guard.Threshold == QuorumAnyReject {
|
|
decisions, err := e.decisions.ListStepDecisions(ctx, state.TaskID, state.CurrentStepID)
|
|
if err != nil {
|
|
return GuardOutcome{Reason: ReasonEvaluationError, Err: fmt.Errorf("load step decisions for quorum: %w", err)}
|
|
}
|
|
satisfied, received := evaluateAnyReject(decisions, guard.Role)
|
|
outcome := GuardOutcome{Satisfied: satisfied, RequiredCount: 1, ReceivedCount: received}
|
|
if !satisfied {
|
|
outcome.Reason = ReasonThresholdNotMet
|
|
}
|
|
return outcome
|
|
}
|
|
|
|
seats, err := e.requiredSeatsForWorkflowRecording(ctx, state.CurrentStepID, state.TaskID, state.WorkflowID, guard.Role, record)
|
|
if err != nil {
|
|
return GuardOutcome{Reason: ReasonEvaluationError, Err: fmt.Errorf("build required slate for quorum: %w", err)}
|
|
}
|
|
if len(seats) == 0 {
|
|
if record {
|
|
e.recordQuorumSlateEmpty(state.TaskID, state.CurrentStepID, guard.Role)
|
|
}
|
|
return GuardOutcome{Reason: ReasonSlateEmpty}
|
|
}
|
|
decisions, err := e.decisions.ListStepDecisions(ctx, state.TaskID, state.CurrentStepID)
|
|
if err != nil {
|
|
return GuardOutcome{Reason: ReasonEvaluationError, Err: fmt.Errorf("load step decisions for quorum: %w", err)}
|
|
}
|
|
return evaluateApproveStyle(guard.Threshold, seats, decisions)
|
|
}
|
|
|
|
// requiredSeats builds the AC-50 required slate for a guard's role at the
|
|
// evaluating step: gather (per-task rows at any step, plus template rows at
|
|
// the evaluating step only) -> filter (role + decision_required) ->
|
|
// canonicalize (AC-44, one row per (task_id, role, agent_profile_id)) ->
|
|
// collapse (AC-20, one row per (role, agent_profile_id), per-task winning
|
|
// over template).
|
|
func (e *Engine) requiredSeats(ctx context.Context, stepID, taskID, role string) ([]ParticipantInfo, error) {
|
|
return e.requiredSeatsForWorkflow(ctx, stepID, taskID, "", role)
|
|
}
|
|
|
|
func (e *Engine) requiredSeatsForWorkflow(ctx context.Context, stepID, taskID, workflowID, role string) ([]ParticipantInfo, error) {
|
|
return e.requiredSeatsForWorkflowRecording(ctx, stepID, taskID, workflowID, role, true)
|
|
}
|
|
|
|
func (e *Engine) requiredSeatsForWorkflowRecording(
|
|
ctx context.Context, stepID, taskID, workflowID, role string, record bool,
|
|
) ([]ParticipantInfo, error) {
|
|
gathered, err := gatherParticipantSlate(ctx, e.participants, stepID, taskID, workflowID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
filtered := make([]ParticipantInfo, 0, len(gathered))
|
|
for _, p := range gathered {
|
|
if p.Role != role || !p.DecisionRequired {
|
|
continue
|
|
}
|
|
filtered = append(filtered, p)
|
|
}
|
|
|
|
canonical := canonicalizeByTaskRoleAgent(filtered, stepID)
|
|
seats := collapseByRoleAgent(canonical)
|
|
return e.dropUnresolvedAgentSeats(ctx, taskID, stepID, role, seats, record)
|
|
}
|
|
|
|
// dropUnresolvedAgentSeats implements AC-OFFICE-REVIEW-SEATS-004.3: a seat
|
|
// whose agent profile has been deleted since the seat was cast is removed
|
|
// from the required slate, so the guard does not wait forever on an agent
|
|
// that can never decide, and continues evaluating the remaining seats rather
|
|
// than failing. Its removal is announced through a warning record and a
|
|
// counter (AC-004.8) instead of just shrinking the slate silently. When no
|
|
// AgentProfileResolver is wired, every seat is kept unchanged — matching
|
|
// every other optional Engine dependency's nil-safe default.
|
|
//
|
|
// collapseByRoleAgent already guarantees at most one seat per (role, agent
|
|
// profile id) pair here, and this function is scoped to one role, so at most
|
|
// one record is emitted per role and unresolved agent per call, without
|
|
// extra dedup state, as long as the caller passes record=true only for an
|
|
// actual guard evaluation or decision write. A read-only observation
|
|
// (ResolveParticipantRoleReadOnly) passes record=false precisely so it does
|
|
// not multiply this emission at its own, unrelated cadence.
|
|
//
|
|
// A resolver error is not the same condition as a confirmed deletion — it
|
|
// means the resolver could not answer, not that it answered "gone" — so it
|
|
// propagates as a slate-construction error (ReasonEvaluationError in
|
|
// computeGuardOutcome) rather than silently dropping the seat.
|
|
func (e *Engine) dropUnresolvedAgentSeats(
|
|
ctx context.Context, taskID, stepID, role string, seats []ParticipantInfo, record bool,
|
|
) ([]ParticipantInfo, error) {
|
|
if e.agentProfiles == nil {
|
|
return seats, nil
|
|
}
|
|
kept := make([]ParticipantInfo, 0, len(seats))
|
|
for _, seat := range seats {
|
|
if seat.AgentProfileID == "" {
|
|
kept = append(kept, seat)
|
|
continue
|
|
}
|
|
exists, err := e.agentProfiles.AgentProfileExists(ctx, seat.AgentProfileID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve agent profile %s for quorum seat: %w", seat.AgentProfileID, err)
|
|
}
|
|
if exists {
|
|
kept = append(kept, seat)
|
|
continue
|
|
}
|
|
if record {
|
|
e.recordParticipantAgentUnresolved(taskID, stepID, role, seat.AgentProfileID)
|
|
}
|
|
}
|
|
return kept, nil
|
|
}
|
|
|
|
// quorumRoleLabel sanitizes an operator-supplied guard/participant role
|
|
// before it becomes an expvar counter label (AC-OFFICE-REVIEW-SEATS-004.6/
|
|
// .11): a role outside the fixed ParticipantRole set folds to the
|
|
// SeatRoleLabelInvalid sentinel already used by the sibling
|
|
// ensure_participant_seat counter, so a malformed or arbitrary guard.Role
|
|
// string cannot grow the metric's label cardinality. The raw string still
|
|
// reaches the paired warning record as a typed field, never the counter.
|
|
func quorumRoleLabel(role string) string {
|
|
if !ValidParticipantRole(role) {
|
|
return SeatRoleLabelInvalid
|
|
}
|
|
return role
|
|
}
|
|
|
|
// recordParticipantAgentUnresolved emits AC-004.3/004.8's warning record and
|
|
// counter for one seat dropped because its agent profile no longer resolves.
|
|
func (e *Engine) recordParticipantAgentUnresolved(taskID, stepID, role, agentProfileID string) {
|
|
quorummetrics.RecordParticipantAgentUnresolved(quorumRoleLabel(role))
|
|
if e.logger == nil {
|
|
return
|
|
}
|
|
e.logger.Warn("workflow quorum participant agent profile unresolved",
|
|
zap.String("task_id", taskID),
|
|
zap.String("step_id", stepID),
|
|
zap.String("role", role),
|
|
zap.String("agent_profile_id", agentProfileID),
|
|
)
|
|
}
|
|
|
|
// recordQuorumSlateEmpty emits AC-004.2/004.8's warning record and counter
|
|
// for a guard whose required slate for role came back empty — no
|
|
// decision-required seat resolved for that role at this step. Distinct from
|
|
// the generic AC-24 recordGuardNotFired unit, which is emitted once by the
|
|
// call site after evaluateTransitionGuard returns; this one is scoped
|
|
// specifically to the empty-slate condition and carries role as a typed
|
|
// field the generic unit does not always have. computeGuardOutcome calls
|
|
// this at most once per evaluation, satisfying AC-004.10's "at most one
|
|
// record per role per guard evaluation" for this condition without extra
|
|
// dedup state.
|
|
func (e *Engine) recordQuorumSlateEmpty(taskID, stepID, role string) {
|
|
quorummetrics.RecordQuorumSlateEmpty(quorumRoleLabel(role))
|
|
if e.logger == nil {
|
|
return
|
|
}
|
|
e.logger.Warn("workflow quorum required slate empty",
|
|
zap.String("task_id", taskID),
|
|
zap.String("step_id", stepID),
|
|
zap.String("role", role),
|
|
)
|
|
}
|
|
|
|
// gatherParticipantSlate implements AC-50 step 1, shared by the quorum guard
|
|
// and every fan-out/resolution call site that must see the same participant
|
|
// population the guard counts: per-task rows for taskID at ANY step
|
|
// (workflow-scoped when the store supports it and workflowID is known)
|
|
// unioned with template rows (task_id="") at the evaluating step only. It
|
|
// does not filter by role/decision_required or dedupe — callers apply their
|
|
// own filter and canonicalizeByTaskRoleAgent/collapseByRoleAgent afterward.
|
|
func gatherParticipantSlate(ctx context.Context, store ParticipantStore, stepID, taskID, workflowID string) ([]ParticipantInfo, error) {
|
|
var perTask []ParticipantInfo
|
|
var err error
|
|
if scoped, ok := store.(WorkflowScopedParticipantStore); ok && workflowID != "" {
|
|
perTask, err = scoped.ListTaskParticipantsForWorkflow(ctx, taskID, workflowID)
|
|
} else {
|
|
perTask, err = store.ListTaskParticipants(ctx, taskID)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list task participants: %w", err)
|
|
}
|
|
template, err := store.ListStepParticipants(ctx, stepID, "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list step participants: %w", err)
|
|
}
|
|
|
|
gathered := make([]ParticipantInfo, 0, len(perTask)+len(template))
|
|
gathered = append(gathered, perTask...)
|
|
gathered = append(gathered, template...)
|
|
return gathered, nil
|
|
}
|
|
|
|
// seatCanonKey identifies a row for AC-44 canonicalization: (task_id, role,
|
|
// agent_profile_id), except a row with no agent_profile_id has no identity
|
|
// to canonicalize on and is kept standalone, keyed by its own row id.
|
|
type seatCanonKey struct {
|
|
taskID, role, agentProfileID, standaloneID string
|
|
}
|
|
|
|
func canonKeyFor(p ParticipantInfo) seatCanonKey {
|
|
if p.AgentProfileID == "" {
|
|
return seatCanonKey{taskID: p.TaskID, role: p.Role, standaloneID: p.ID}
|
|
}
|
|
return seatCanonKey{taskID: p.TaskID, role: p.Role, agentProfileID: p.AgentProfileID}
|
|
}
|
|
|
|
// canonicalizeByTaskRoleAgent implements AC-44: one row per (task_id, role,
|
|
// agent_profile_id), preferring the row at the evaluating step, else the
|
|
// row with the lowest id in ASCII order.
|
|
func canonicalizeByTaskRoleAgent(rows []ParticipantInfo, evaluatingStepID string) []ParticipantInfo {
|
|
groups := map[seatCanonKey][]ParticipantInfo{}
|
|
var order []seatCanonKey
|
|
for _, p := range rows {
|
|
k := canonKeyFor(p)
|
|
if _, ok := groups[k]; !ok {
|
|
order = append(order, k)
|
|
}
|
|
groups[k] = append(groups[k], p)
|
|
}
|
|
out := make([]ParticipantInfo, 0, len(order))
|
|
for _, k := range order {
|
|
out = append(out, pickCanonicalRow(groups[k], evaluatingStepID))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func pickCanonicalRow(rows []ParticipantInfo, evaluatingStepID string) ParticipantInfo {
|
|
for _, r := range rows {
|
|
if r.StepID == evaluatingStepID {
|
|
return r
|
|
}
|
|
}
|
|
best := rows[0]
|
|
for _, r := range rows[1:] {
|
|
if r.ID < best.ID {
|
|
best = r
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// seatCollapseKey identifies a row for AC-20 collapse: (role,
|
|
// agent_profile_id), except a row with no agent_profile_id is kept
|
|
// standalone, keyed by its own row id.
|
|
type seatCollapseKey struct {
|
|
role, agentProfileID, standaloneID string
|
|
}
|
|
|
|
func collapseKeyFor(p ParticipantInfo) seatCollapseKey {
|
|
if p.AgentProfileID == "" {
|
|
return seatCollapseKey{role: p.Role, standaloneID: p.ID}
|
|
}
|
|
return seatCollapseKey{role: p.Role, agentProfileID: p.AgentProfileID}
|
|
}
|
|
|
|
// collapseByRoleAgent implements AC-50 step 4 / AC-20: collapse across
|
|
// task_id to one row per (role, agent_profile_id), the per-task row (non
|
|
// empty task_id) winning over a template row.
|
|
func collapseByRoleAgent(rows []ParticipantInfo) []ParticipantInfo {
|
|
groups := map[seatCollapseKey][]ParticipantInfo{}
|
|
var order []seatCollapseKey
|
|
for _, p := range rows {
|
|
k := collapseKeyFor(p)
|
|
if _, ok := groups[k]; !ok {
|
|
order = append(order, k)
|
|
}
|
|
groups[k] = append(groups[k], p)
|
|
}
|
|
out := make([]ParticipantInfo, 0, len(order))
|
|
for _, k := range order {
|
|
out = append(out, pickPerTaskOverTemplate(groups[k]))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func pickPerTaskOverTemplate(rows []ParticipantInfo) ParticipantInfo {
|
|
for _, r := range rows {
|
|
if r.TaskID != "" {
|
|
return r
|
|
}
|
|
}
|
|
best := rows[0]
|
|
for _, r := range rows[1:] {
|
|
if r.ID < best.ID {
|
|
best = r
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// mapDecisionsToSeats implements AC-51/AC-26: maps each decision to at most
|
|
// one seat — by participant_id when it names a seat, otherwise by
|
|
// (role, decider_id) for decider_type=agent — and keeps the latest mapped
|
|
// decision per seat under the AC-26 ordering (decisions arrive oldest
|
|
// first; a later write overwrites an earlier one). A decision that maps to
|
|
// no seat is dropped, per AC-43a.
|
|
func mapDecisionsToSeats(seats []ParticipantInfo, decisions []DecisionInfo) map[string]DecisionInfo {
|
|
seatByID := make(map[string]struct{}, len(seats))
|
|
seatByRoleAgent := make(map[[2]string]string, len(seats))
|
|
for _, s := range seats {
|
|
seatByID[s.ID] = struct{}{}
|
|
if s.AgentProfileID != "" {
|
|
seatByRoleAgent[[2]string{s.Role, s.AgentProfileID}] = s.ID
|
|
}
|
|
}
|
|
|
|
latest := make(map[string]DecisionInfo, len(seats))
|
|
for _, d := range decisions {
|
|
seatID := ""
|
|
if _, ok := seatByID[d.ParticipantID]; ok && d.ParticipantID != "" {
|
|
seatID = d.ParticipantID
|
|
} else if d.DeciderType == DeciderTypeAgent {
|
|
if sid, ok := seatByRoleAgent[[2]string{d.Role, d.DeciderID}]; ok {
|
|
seatID = sid
|
|
}
|
|
}
|
|
if seatID == "" {
|
|
continue
|
|
}
|
|
latest[seatID] = d
|
|
}
|
|
return latest
|
|
}
|
|
|
|
// evaluateAnyReject implements AC-43/AC-43a/AC-43b/AC-58/AC-59: any_reject
|
|
// is a decider-identity veto, not a seated quorum contribution. The LAST
|
|
// decision per (decider_type, decider_id) under the AC-26 ordering decides
|
|
// whether that decider currently vetoes. Agent deciders are filtered to the
|
|
// guard's role (AC-42); user deciders are role-agnostic (AC-58), waiving
|
|
// resolveDeciderRole's approver-wins quirk so a human rejection counts
|
|
// regardless of the stored role. The slate is never consulted — a seatless
|
|
// veto is exactly what this threshold exists to allow.
|
|
func evaluateAnyReject(decisions []DecisionInfo, guardRole string) (satisfied bool, receivedCount int) {
|
|
type identity struct{ deciderType, deciderID string }
|
|
latest := map[identity]DecisionInfo{}
|
|
for _, d := range decisions {
|
|
if d.DeciderType == "" || d.DeciderID == "" {
|
|
continue
|
|
}
|
|
if d.DeciderType == DeciderTypeAgent && d.Role != guardRole {
|
|
continue
|
|
}
|
|
latest[identity{d.DeciderType, d.DeciderID}] = d
|
|
}
|
|
vetoCount := 0
|
|
for _, d := range latest {
|
|
if isRejectionVerdict(d.Decision) {
|
|
vetoCount++
|
|
}
|
|
}
|
|
return vetoCount > 0, vetoCount
|
|
}
|
|
|
|
// evaluateApproveStyle implements AC-21/AC-39/AC-40/AC-41/AC-43a/AC-53: the
|
|
// approve-style thresholds (all_approve, all_decide, majority_approve,
|
|
// n_approve:<N>), counting only decisions mapped to an AC-50 seat.
|
|
func evaluateApproveStyle(threshold string, seats []ParticipantInfo, decisions []DecisionInfo) GuardOutcome {
|
|
mapped := mapDecisionsToSeats(seats, decisions)
|
|
approveCount, decidedCount := 0, 0
|
|
for _, d := range mapped {
|
|
decidedCount++
|
|
if d.Decision == DecisionApproved {
|
|
approveCount++
|
|
}
|
|
}
|
|
total := len(seats)
|
|
|
|
switch {
|
|
case threshold == QuorumAllApprove:
|
|
return finishApproveStyle(approveCount == total, total, approveCount)
|
|
case threshold == QuorumAllDecide:
|
|
return finishApproveStyle(decidedCount == total, total, decidedCount)
|
|
case threshold == QuorumMajorityApprove:
|
|
return finishApproveStyle(approveCount*2 > total, total, approveCount)
|
|
case strings.HasPrefix(threshold, QuorumNApprovePrefix):
|
|
return evaluateNApprove(threshold, total, approveCount)
|
|
default:
|
|
return GuardOutcome{Reason: ReasonThresholdUnrecognized}
|
|
}
|
|
}
|
|
|
|
func evaluateNApprove(threshold string, total, approveCount int) GuardOutcome {
|
|
n, err := strconv.Atoi(strings.TrimPrefix(threshold, QuorumNApprovePrefix))
|
|
if err != nil || n <= 0 {
|
|
return GuardOutcome{Reason: ReasonThresholdUnrecognized}
|
|
}
|
|
if n > total {
|
|
return GuardOutcome{Reason: ReasonThresholdUnsatisfiable, RequiredCount: n, ReceivedCount: approveCount}
|
|
}
|
|
return finishApproveStyle(approveCount >= n, n, approveCount)
|
|
}
|
|
|
|
func finishApproveStyle(satisfied bool, required, received int) GuardOutcome {
|
|
outcome := GuardOutcome{Satisfied: satisfied, RequiredCount: required, ReceivedCount: received}
|
|
if !satisfied {
|
|
outcome.Reason = ReasonThresholdNotMet
|
|
}
|
|
return outcome
|
|
}
|
|
|
|
// RecordDecisionResult reports the outcome of RecordParticipantDecision: the
|
|
// stamped decision identity (AC-66/57b-i) plus what happened to
|
|
// re-evaluation.
|
|
type RecordDecisionResult struct {
|
|
DecisionID string
|
|
DecidedAt time.Time
|
|
|
|
// ReevaluationSkipped is true under AC-16a/F39: the decision was
|
|
// recorded but re-evaluation did not run because sessionID was blank
|
|
// (the caller could not resolve the task's active session). SkipReason
|
|
// is ReasonSessionUnresolvable in that case.
|
|
ReevaluationSkipped bool
|
|
SkipReason string
|
|
|
|
// Transitioned / TransitionAbandoned / FromStepID / ToStepID mirror
|
|
// HandleResult's fields for the transition (if any) the re-evaluation
|
|
// applied.
|
|
Transitioned bool
|
|
TransitionAbandoned bool
|
|
FromStepID string
|
|
ToStepID string
|
|
// Guards is the pre-transition quorum snapshot used by the decision
|
|
// response. It remains available after a successful CAS move.
|
|
Guards []QuorumGuardState
|
|
}
|
|
|
|
// RecordParticipantDecision is the engine's single decision-recording entry
|
|
// point (AC-57): it writes a new decision row, stamping ID and DecidedAt
|
|
// itself (AC-66/57b-i) since DecisionStore.RecordStepDecision takes
|
|
// DecisionInfo by value and never echoes generated fields back. When
|
|
// sessionID resolves, it then re-evaluates — in "committing mode", applying
|
|
// at most one satisfied transition itself (AC-46/47/65) — only the guarded
|
|
// on_turn_complete transition actions at the decision's step, never a plain
|
|
// HandleTrigger(TriggerOnTurnComplete) call, which would re-fire every
|
|
// on_turn_complete action (queue_run, clear_decisions, ...) once per
|
|
// decision instead of once per completed turn.
|
|
//
|
|
// in.ID and in.DecidedAt are ignored — always overwritten with a fresh
|
|
// UUID and the current time. sessionID is optional: blank means the caller
|
|
// could not resolve the task's active session (AC-16a), so the decision is
|
|
// recorded and re-evaluation is skipped without erroring, reported under
|
|
// AC-23 as ReasonSessionUnresolvable so it's distinguishable from
|
|
// "re-evaluated and found threshold unmet."
|
|
func (e *Engine) RecordParticipantDecision(
|
|
ctx context.Context,
|
|
sessionID string,
|
|
in DecisionInfo,
|
|
) (RecordDecisionResult, error) {
|
|
if e.decisions == nil {
|
|
return RecordDecisionResult{}, fmt.Errorf("workflow engine: decision store not wired")
|
|
}
|
|
if in.TaskID == "" || in.StepID == "" || in.ParticipantID == "" {
|
|
return RecordDecisionResult{}, fmt.Errorf("record decision requires task_id, step_id, and participant_id")
|
|
}
|
|
if in.Decision == "" {
|
|
return RecordDecisionResult{}, fmt.Errorf("record decision verdict must not be empty")
|
|
}
|
|
|
|
in.ID = uuid.New().String()
|
|
in.DecidedAt = time.Now().UTC()
|
|
if err := e.decisions.RecordStepDecision(ctx, in); err != nil {
|
|
return RecordDecisionResult{}, err
|
|
}
|
|
|
|
result := RecordDecisionResult{DecisionID: in.ID, DecidedAt: in.DecidedAt}
|
|
if sessionID == "" {
|
|
result.ReevaluationSkipped = true
|
|
result.SkipReason = ReasonSessionUnresolvable
|
|
e.recordReevaluationSkip(in.TaskID, in.StepID)
|
|
return result, nil
|
|
}
|
|
|
|
handled, err := e.reevaluateGuardedTransitions(ctx, in.TaskID, sessionID, in.StepID, in.ID)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
result.Transitioned = handled.Transitioned
|
|
result.TransitionAbandoned = handled.TransitionAbandoned
|
|
result.FromStepID = handled.FromStepID
|
|
result.ToStepID = handled.ToStepID
|
|
result.Guards = handled.Guards
|
|
return result, nil
|
|
}
|
|
|
|
// reevaluateGuardedTransitions implements the AC-13-17/AC-65 re-evaluation
|
|
// scope: it loads the step the decision was recorded against directly (not
|
|
// the task's possibly-since-moved current step — the CAS apply below is
|
|
// what protects against that race) and applies at most the first satisfied
|
|
// guarded on_turn_complete transition action, in configured order. Actions
|
|
// without a wait_for_quorum guard, and every non-transition action
|
|
// (queue_run, clear_decisions, ...), are out of scope for this path — they
|
|
// already ran once for the turn that produced these decisions and must not
|
|
// re-run per decision.
|
|
//
|
|
// The AC-30 operation id is marked applied only once this function has
|
|
// fully determined the outcome (transitioned, abandoned, or nothing
|
|
// satisfied) — never before, so a crash mid-evaluation does not leave a
|
|
// decision's re-evaluation permanently skipped.
|
|
func (e *Engine) reevaluateGuardedTransitions(
|
|
ctx context.Context, taskID, sessionID, stepID, decisionID string,
|
|
) (HandleResult, error) {
|
|
opID := fmt.Sprintf("decision:%s:%s:%s", taskID, stepID, decisionID)
|
|
applied, err := e.store.IsOperationApplied(ctx, opID)
|
|
if err != nil {
|
|
return HandleResult{}, err
|
|
}
|
|
if applied {
|
|
return HandleResult{Idempotent: true}, nil
|
|
}
|
|
|
|
state, err := e.store.LoadState(ctx, taskID, sessionID)
|
|
if err != nil {
|
|
return HandleResult{}, err
|
|
}
|
|
state.CurrentStepID = stepID
|
|
step, err := e.store.LoadStep(ctx, state.WorkflowID, stepID)
|
|
if err != nil {
|
|
return HandleResult{}, err
|
|
}
|
|
|
|
result, err := e.applyFirstSatisfiedGuardedTransition(ctx, taskID, sessionID, state, step)
|
|
if err != nil {
|
|
return HandleResult{}, err
|
|
}
|
|
if err := e.markOperationApplied(ctx, opID); err != nil {
|
|
return HandleResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// applyFirstSatisfiedGuardedTransition evaluates on_turn_complete's guarded
|
|
// transition actions in order and, for the first one whose wait_for_quorum
|
|
// guard is satisfied, applies it via the AC-46/48 compare-and-swap. Emits
|
|
// the AC-24/24a observability unit for every guard that is evaluated and
|
|
// does not fire, same as the ordinary HandleTrigger path.
|
|
//
|
|
//nolint:cyclop // ordered guard evaluation keeps first-transition semantics explicit.
|
|
func (e *Engine) applyFirstSatisfiedGuardedTransition(
|
|
ctx context.Context, taskID, sessionID string, state MachineState, step StepSpec,
|
|
) (HandleResult, error) {
|
|
guards := make([]QuorumGuardState, 0)
|
|
var selectedTarget string
|
|
for _, action := range step.Events[TriggerOnTurnComplete] {
|
|
if !isTransitionAction(action.Kind) || action.RequiresApproval {
|
|
continue
|
|
}
|
|
if action.Guard == nil || action.Guard.WaitForQuorum == nil {
|
|
continue
|
|
}
|
|
entry, targetStepID, targetErr, satisfied := e.evaluateGuardForTransition(ctx, state, step, action)
|
|
guards = append(guards, entry)
|
|
if targetErr != nil {
|
|
// Preserve the old write-side behavior for the first satisfied
|
|
// guard, which returned this target-resolution error. A malformed
|
|
// later guard must not prevent an earlier satisfied transition from
|
|
// firing, and an unmet guard can still report its diagnostic state.
|
|
if satisfied && selectedTarget == "" {
|
|
return HandleResult{}, targetErr
|
|
}
|
|
e.recordGuardNotFired(state, action, GuardOutcome{Reason: entry.Reason, Err: entry.Error, RequiredCount: entry.RequiredCount, ReceivedCount: entry.ReceivedCount})
|
|
continue
|
|
}
|
|
if !satisfied {
|
|
e.recordGuardNotFired(state, action, GuardOutcome{Reason: entry.Reason, Err: entry.Error, RequiredCount: entry.RequiredCount, ReceivedCount: entry.ReceivedCount})
|
|
continue
|
|
}
|
|
if targetStepID == "" || targetStepID == state.CurrentStepID {
|
|
continue
|
|
}
|
|
if selectedTarget == "" {
|
|
selectedTarget = targetStepID
|
|
}
|
|
}
|
|
if selectedTarget == "" {
|
|
return HandleResult{Guards: guards}, nil
|
|
}
|
|
applied, err := e.store.ApplyTransitionIfAtStep(
|
|
ctx, taskID, sessionID, state.CurrentStepID, selectedTarget, TriggerOnTurnComplete,
|
|
)
|
|
if err != nil {
|
|
return HandleResult{}, err
|
|
}
|
|
if !applied {
|
|
return HandleResult{Guards: guards, TransitionAbandoned: true, FromStepID: state.CurrentStepID, ToStepID: selectedTarget}, nil
|
|
}
|
|
return HandleResult{Guards: guards, Transitioned: true, FromStepID: state.CurrentStepID, ToStepID: selectedTarget}, nil
|
|
}
|
|
|
|
func (e *Engine) evaluateGuardForTransition(
|
|
ctx context.Context, state MachineState, step StepSpec, action Action,
|
|
) (QuorumGuardState, string, error, bool) {
|
|
outcome := e.evaluateTransitionGuard(ctx, state, action)
|
|
targetStepID, targetErr := e.resolveTransitionTarget(ctx, state, step, action)
|
|
if targetErr != nil {
|
|
return QuorumGuardState{
|
|
TargetStepID: targetStepID,
|
|
Role: action.Guard.WaitForQuorum.Role,
|
|
Threshold: action.Guard.WaitForQuorum.Threshold,
|
|
Satisfied: false,
|
|
Reason: ReasonEvaluationError,
|
|
Error: targetErr,
|
|
}, targetStepID, targetErr, outcome.Satisfied
|
|
}
|
|
return QuorumGuardState{
|
|
TargetStepID: targetStepID,
|
|
Role: action.Guard.WaitForQuorum.Role,
|
|
Threshold: action.Guard.WaitForQuorum.Threshold,
|
|
RequiredCount: outcome.RequiredCount,
|
|
ReceivedCount: outcome.ReceivedCount,
|
|
Satisfied: outcome.Satisfied,
|
|
Reason: outcome.Reason,
|
|
Error: outcome.Err,
|
|
}, targetStepID, nil, outcome.Satisfied
|
|
}
|
|
|
|
// QuorumGuardState is one guarded transition's read-only quorum state,
|
|
// returned by EvaluateStepQuorum (AC-54/57d). Satisfied=true entries omit
|
|
// Reason entirely (AC-60).
|
|
type QuorumGuardState struct {
|
|
TargetStepID string
|
|
Role string
|
|
Threshold string
|
|
RequiredCount int
|
|
ReceivedCount int
|
|
Satisfied bool
|
|
Reason string
|
|
Error error
|
|
}
|
|
|
|
// QuorumSnapshot is the AC-54/57d read-only diagnostic snapshot for a
|
|
// task's current step.
|
|
type QuorumSnapshot struct {
|
|
StepID string
|
|
// ReevaluationBlocked reflects only the "task has at least one decision
|
|
// at its current step" conjunct (AC-62) — the engine has no access to
|
|
// the AC-16 active-session query, so it cannot compute the full
|
|
// conjunction with "no active session" by itself. The caller
|
|
// (shared.WorkflowEngineDispatcher's EvaluateStepQuorum, which does have
|
|
// session-resolution access) ANDs in that second conjunct.
|
|
ReevaluationBlocked bool
|
|
// Guards is ordered to match the step's configured on_turn_complete
|
|
// action order (AC-57d), tying AC-61's selection rule to AC-17's
|
|
// first-transition-wins.
|
|
Guards []QuorumGuardState
|
|
}
|
|
|
|
// EvaluateStepQuorum computes the read-only AC-54/57d snapshot for the step
|
|
// the given session currently resolves to. sessionID is used only to
|
|
// satisfy TransitionStore.LoadState's signature — MachineState.CurrentStepID
|
|
// is derived from the task row, not the session, so any valid session for
|
|
// the task yields the correct step (see production TransitionStore
|
|
// implementations). Applies no transition, writes no row, executes no
|
|
// action callback, and emits none of the guard-evaluation observability
|
|
// units — no AC-24/24a guard-not-fired log/counter, and no AC-004.2/004.3/
|
|
// 004.8 slate-empty or agent-unresolved log/counter — a diagnostic read
|
|
// never fails on, or mutates, the state it exists to diagnose. Guard
|
|
// evaluation is routed through the ...Recording(..., record bool) variants
|
|
// with record=false for this reason: EvaluateStepQuorum is dashboard-polled,
|
|
// so counting or logging its polls would inflate AC-004.10's "at most one
|
|
// record per guard evaluation" intent for every poll instead of every real
|
|
// evaluation.
|
|
//
|
|
// A store error while evaluating one guard surfaces as that guard's
|
|
// ReasonEvaluationError entry, not a method-level error, so one failing
|
|
// guard does not blank the others. A task with no bound current step
|
|
// returns an empty, no-error snapshot (AC-24c). sessionID == "" (a task
|
|
// that has never had a session) is NOT special-cased to an empty snapshot:
|
|
// TransitionStore.LoadState derives CurrentStepID from the task row, not
|
|
// the session, so a blank sessionID still yields the correct step, and
|
|
// AC-62's ReevaluationBlocked must be computed live from that step's
|
|
// decisions rather than short-circuited to false.
|
|
func (e *Engine) EvaluateStepQuorum(ctx context.Context, taskID, sessionID string) (QuorumSnapshot, error) {
|
|
state, err := e.store.LoadState(ctx, taskID, sessionID)
|
|
if err != nil {
|
|
return QuorumSnapshot{}, err
|
|
}
|
|
if state.CurrentStepID == "" {
|
|
return QuorumSnapshot{}, nil
|
|
}
|
|
step, err := e.store.LoadStep(ctx, state.WorkflowID, state.CurrentStepID)
|
|
if err != nil {
|
|
return QuorumSnapshot{}, err
|
|
}
|
|
|
|
snapshot := QuorumSnapshot{StepID: state.CurrentStepID}
|
|
for _, action := range step.Events[TriggerOnTurnComplete] {
|
|
if !isTransitionAction(action.Kind) || action.Guard == nil {
|
|
continue
|
|
}
|
|
snapshot.Guards = append(snapshot.Guards, e.evaluateGuardStateReadOnly(ctx, state, step, action))
|
|
}
|
|
|
|
if e.decisions != nil {
|
|
decisions, derr := e.decisions.ListStepDecisions(ctx, taskID, state.CurrentStepID)
|
|
if derr == nil {
|
|
snapshot.ReevaluationBlocked = len(decisions) > 0
|
|
}
|
|
}
|
|
|
|
return snapshot, nil
|
|
}
|
|
|
|
// evaluateGuardStateReadOnly computes one QuorumGuardState entry without any
|
|
// side effect (no logging, no counters, no apply).
|
|
func (e *Engine) evaluateGuardStateReadOnly(
|
|
ctx context.Context, state MachineState, step StepSpec, action Action,
|
|
) QuorumGuardState {
|
|
outcome := e.evaluateTransitionGuardRecording(ctx, state, action, false)
|
|
targetStepID, err := e.resolveTransitionTarget(ctx, state, step, action)
|
|
if err != nil {
|
|
outcome = GuardOutcome{Reason: ReasonEvaluationError, Err: err}
|
|
}
|
|
entry := QuorumGuardState{
|
|
TargetStepID: targetStepID,
|
|
Satisfied: outcome.Satisfied,
|
|
Reason: outcome.Reason,
|
|
Error: outcome.Err,
|
|
RequiredCount: outcome.RequiredCount,
|
|
ReceivedCount: outcome.ReceivedCount,
|
|
}
|
|
if action.Guard.WaitForQuorum != nil {
|
|
entry.Role = action.Guard.WaitForQuorum.Role
|
|
entry.Threshold = action.Guard.WaitForQuorum.Threshold
|
|
}
|
|
return entry
|
|
}
|
|
|
|
// ResolveParticipantRole resolves the role and seat id an agent decider
|
|
// occupies at stepID, for the agent decision tool's AC-2 (seat id),
|
|
// AC-3 (permission), AC-4 (approver-wins) and AC-4a (slate-consistent
|
|
// population) requirements. It reuses requiredSeats — the exact AC-50
|
|
// slate construction the quorum evaluator itself uses — so role
|
|
// resolution and slate membership can never disagree about who
|
|
// participates, per AC-57's "exactly once, in the engine" mandate.
|
|
// When the caller holds both seats, the seat whose StepID equals stepID
|
|
// wins, since that is the seat the current step's guard actually reads
|
|
// decisions against. When neither seat sits at stepID, the role named by
|
|
// stepID's own wait_for_quorum guard wins, so a decision is never filed
|
|
// under a role no guard at that step is counting. Approver wins as the
|
|
// final tiebreak — when the step names both roles, names neither, or the
|
|
// step cannot be loaded — matching the precedence in
|
|
// office/dashboard/decisions.go's resolveDeciderRole (AC-4). Returns
|
|
// ErrParticipantNotFound when the agent occupies neither seat.
|
|
//
|
|
// This is the recording variant: an unresolved-agent seat dropped from the
|
|
// slate emits a counter and warning log scoped to guard evaluations.
|
|
// Reserve it for callers that ARE a guard evaluation or a real decision
|
|
// write (RecordAgentDecision's authorization check). A caller that merely
|
|
// observes seat occupancy — without evaluating a guard or recording a
|
|
// decision — must use ResolveParticipantRoleReadOnly instead, or every such
|
|
// observation double-counts the same drop.
|
|
func (e *Engine) ResolveParticipantRole(
|
|
ctx context.Context, taskID, stepID, agentProfileID string,
|
|
) (role, participantID string, err error) {
|
|
return e.resolveParticipantRole(ctx, taskID, stepID, agentProfileID, true)
|
|
}
|
|
|
|
// ResolveParticipantRoleReadOnly is ResolveParticipantRole without the
|
|
// unresolved-agent counter/log side effect, mirroring
|
|
// evaluateGuardStateReadOnly's use of requiredSeatsForWorkflowRecording(...,
|
|
// false) for the same reason: the caller is observing seat occupancy, not
|
|
// evaluating a guard or recording a decision, so the emission stays scoped
|
|
// to actual guard evaluations rather than firing on every observation.
|
|
func (e *Engine) ResolveParticipantRoleReadOnly(
|
|
ctx context.Context, taskID, stepID, agentProfileID string,
|
|
) (role, participantID string, err error) {
|
|
return e.resolveParticipantRole(ctx, taskID, stepID, agentProfileID, false)
|
|
}
|
|
|
|
func (e *Engine) resolveParticipantRole(
|
|
ctx context.Context, taskID, stepID, agentProfileID string, record bool,
|
|
) (role, participantID string, err error) {
|
|
if agentProfileID == "" {
|
|
return "", "", ErrParticipantNotFound
|
|
}
|
|
workflowID := ""
|
|
if state, stateErr := e.store.LoadState(ctx, taskID, ""); stateErr == nil {
|
|
workflowID = state.WorkflowID
|
|
}
|
|
|
|
var matches []roleSeat
|
|
var firstSeatsErr error
|
|
for _, r := range []wfmodels.ParticipantRole{
|
|
wfmodels.ParticipantRoleApprover,
|
|
wfmodels.ParticipantRoleReviewer,
|
|
} {
|
|
seats, seatsErr := e.requiredSeatsForWorkflowRecording(ctx, stepID, taskID, workflowID, string(r), record)
|
|
if seatsErr != nil {
|
|
if firstSeatsErr == nil {
|
|
firstSeatsErr = seatsErr
|
|
}
|
|
continue
|
|
}
|
|
if seat, ok := seatFor(seats, agentProfileID); ok {
|
|
if seat.StepID == stepID {
|
|
return string(r), seat.ID, nil
|
|
}
|
|
matches = append(matches, roleSeat{role: string(r), seat: seat})
|
|
}
|
|
}
|
|
if firstSeatsErr != nil {
|
|
return "", "", fmt.Errorf("resolve participant role: %w", firstSeatsErr)
|
|
}
|
|
if len(matches) == 0 {
|
|
return "", "", ErrParticipantNotFound
|
|
}
|
|
if guardRoles := e.guardRolesAtStep(ctx, workflowID, stepID); len(guardRoles) == 1 {
|
|
for _, m := range matches {
|
|
if guardRoles[m.role] {
|
|
return m.role, m.seat.ID, nil
|
|
}
|
|
}
|
|
}
|
|
return matches[0].role, matches[0].seat.ID, nil
|
|
}
|
|
|
|
// guardRolesAtStep returns the set of roles named by wait_for_quorum guards
|
|
// on stepID's eligible on_turn_complete transitions, used to break a
|
|
// cross-step role resolution tie in favor of the role the step is waiting on.
|
|
// Returns an empty set when the step names no such guard or cannot be
|
|
// loaded, so callers fall back to approver-wins rather than treating a load
|
|
// failure as a role match.
|
|
func (e *Engine) guardRolesAtStep(ctx context.Context, workflowID, stepID string) map[string]bool {
|
|
if workflowID == "" {
|
|
return nil
|
|
}
|
|
step, err := e.store.LoadStep(ctx, workflowID, stepID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
roles := make(map[string]bool)
|
|
for _, action := range step.Events[TriggerOnTurnComplete] {
|
|
if !isTransitionAction(action.Kind) || action.RequiresApproval {
|
|
continue
|
|
}
|
|
if action.Guard == nil || action.Guard.WaitForQuorum == nil || action.Guard.WaitForQuorum.Role == "" {
|
|
continue
|
|
}
|
|
roles[action.Guard.WaitForQuorum.Role] = true
|
|
}
|
|
return roles
|
|
}
|
|
|
|
// roleSeat pairs a resolved role with the seat matched under it.
|
|
type roleSeat struct {
|
|
role string
|
|
seat ParticipantInfo
|
|
}
|
|
|
|
// seatFor scans seats for one occupied by agentProfileID.
|
|
func seatFor(seats []ParticipantInfo, agentProfileID string) (ParticipantInfo, bool) {
|
|
for _, s := range seats {
|
|
if s.AgentProfileID == agentProfileID {
|
|
return s, true
|
|
}
|
|
}
|
|
return ParticipantInfo{}, false
|
|
}
|