Files
kandev/apps/backend/internal/workflow/engine/quorum.go
T
2026-05-16 20:07:57 +01:00

204 lines
7.0 KiB
Go

package engine
import (
"context"
"fmt"
"strconv"
"strings"
"time"
)
// 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" are
// counted by the canned thresholds.
const (
DecisionApproved = "approved"
DecisionRejected = "rejected"
)
// evaluateTransitionGuard returns true if the action's guard permits the
// transition (or if the action carries no guard at all). When false, the
// engine skips this action and keeps looking for the next eligible
// transition — preserving the first-transition-wins rule.
//
// A guard that references quorum but has no DecisionStore wired returns
// false: better to stall a transition than fire one whose precondition the
// engine cannot evaluate.
func (e *Engine) evaluateTransitionGuard(ctx context.Context, state MachineState, action Action) (bool, error) {
if action.Guard == nil {
return true, nil
}
if action.Guard.WaitForQuorum != nil {
return e.evaluateWaitForQuorum(ctx, state, action.Guard.WaitForQuorum)
}
// Unknown guard variant — fail closed.
return false, nil
}
// evaluateWaitForQuorum reads the (task, step) decisions and the step's
// participants and applies the configured threshold.
func (e *Engine) evaluateWaitForQuorum(ctx context.Context, state MachineState, guard *WaitForQuorumGuard) (bool, error) {
if e.decisions == nil || e.participants == nil {
return false, nil
}
required, err := e.requiredParticipants(ctx, state.CurrentStepID, state.TaskID, guard.Role)
if err != nil {
return false, err
}
if len(required) == 0 {
// No required reviewers — guard cannot be satisfied. Fail closed.
return false, nil
}
decisions, err := e.decisions.ListStepDecisions(ctx, state.TaskID, state.CurrentStepID)
if err != nil {
return false, fmt.Errorf("load step decisions for quorum: %w", err)
}
return applyThreshold(guard.Threshold, required, decisions), nil
}
// requiredParticipants returns the participants for the (step, task) pair
// whose role matches and whose DecisionRequired flag is true.
func (e *Engine) requiredParticipants(ctx context.Context, stepID, taskID, role string) ([]ParticipantInfo, error) {
all, err := e.participants.ListStepParticipants(ctx, stepID, taskID)
if err != nil {
return nil, fmt.Errorf("list step participants for quorum: %w", err)
}
required := make([]ParticipantInfo, 0, len(all))
for _, p := range all {
if p.Role != role || !p.DecisionRequired {
continue
}
required = append(required, p)
}
return required, nil
}
// applyThreshold counts decisions against the required participant slate
// and reports whether the threshold is met. Decisions whose participant_id
// is not in the required slate are ignored — that's how mid-flight removal
// drops the missing slot from the count.
func applyThreshold(threshold string, required []ParticipantInfo, decisions []DecisionInfo) bool {
requiredIDs := participantIDSet(required)
latest := latestDecisionsPerParticipant(decisions, requiredIDs)
approveCount := 0
rejectCount := 0
decidedCount := 0
for _, d := range latest {
decidedCount++
switch d.Decision {
case DecisionApproved:
approveCount++
case DecisionRejected:
rejectCount++
}
}
totalRequired := len(required)
switch {
case threshold == QuorumAllApprove:
return approveCount == totalRequired
case threshold == QuorumAllDecide:
return decidedCount == totalRequired
case threshold == QuorumAnyReject:
return rejectCount > 0
case threshold == QuorumMajorityApprove:
return approveCount*2 > totalRequired
case strings.HasPrefix(threshold, QuorumNApprovePrefix):
n, err := strconv.Atoi(strings.TrimPrefix(threshold, QuorumNApprovePrefix))
if err != nil || n <= 0 {
return false
}
return approveCount >= n
}
return false
}
// participantIDSet returns the set of participant IDs in the slate.
func participantIDSet(participants []ParticipantInfo) map[string]struct{} {
set := make(map[string]struct{}, len(participants))
for _, p := range participants {
set[p.ID] = struct{}{}
}
return set
}
// latestDecisionsPerParticipant keeps only the most recent decision per
// participant whose ID is present in the required set. Decisions for
// participants no longer required (removed mid-flight) are dropped.
func latestDecisionsPerParticipant(decisions []DecisionInfo, required map[string]struct{}) map[string]DecisionInfo {
latest := make(map[string]DecisionInfo, len(required))
for _, d := range decisions {
if _, ok := required[d.ParticipantID]; !ok {
continue
}
// Decisions arrive oldest-first per the repo contract; later writes
// overwrite earlier ones so we keep the latest verdict per participant.
latest[d.ParticipantID] = d
}
return latest
}
// RecordParticipantDecision writes a new decision row and immediately re-
// evaluates pending transitions for that (task, step) so any wait_for_quorum
// guard that just became satisfied can fire.
//
// The returned HandleResult mirrors HandleTrigger: Transitioned indicates a
// transition was applied. EvaluateOnly is honoured if set on the input.
//
// Callers that want to suppress re-evaluation (e.g. record-only flows) can
// pass an empty EvaluateInput by leaving Trigger blank — only the recording
// step runs in that case.
func (e *Engine) RecordParticipantDecision(
ctx context.Context,
taskID, sessionID, stepID, participantID, decision, note string,
) error {
if e.decisions == nil {
return fmt.Errorf("workflow engine: decision store not wired")
}
if taskID == "" || stepID == "" || participantID == "" {
return fmt.Errorf("record decision requires task_id, step_id, and participant_id")
}
if decision == "" {
return fmt.Errorf("record decision verdict must not be empty")
}
if err := e.decisions.RecordStepDecision(ctx, DecisionInfo{
TaskID: taskID,
StepID: stepID,
ParticipantID: participantID,
Decision: decision,
Note: note,
}); err != nil {
return err
}
// Re-evaluate transitions: fire on_turn_complete in evaluate-only mode so
// callers (the office service) receive the transition payload and decide
// how to apply it. Idempotency is keyed off a synthetic operation id.
//
// SessionID is optional here — when blank we skip re-evaluation rather
// than synthesise a fake session. Callers that want a re-eval pass the
// canonical session id for the (task, step) — typically the task's
// primary session.
if sessionID == "" {
return nil
}
_, err := e.HandleTrigger(ctx, HandleInput{
TaskID: taskID,
SessionID: sessionID,
Trigger: TriggerOnTurnComplete,
EvaluateOnly: true,
OperationID: fmt.Sprintf("decision:%s:%s:%s:%d", taskID, stepID, participantID, time.Now().UnixNano()),
})
return err
}