Files
poimen-workflows/internal/approval/gate.go
T
Test 75a01a9444 feat(T3.4): implement human-in-the-loop approval gates
- Add internal/approval package for workflow approval gates
- Implement ApprovalGate for gating workflow progression
- Implement ApprovalGateManager for managing multiple gates
- Gate status tracking: pending, approved, rejected, expired
- TTL-based gate expiration (auto-expire after timeout)
- Multiple approval tracking (configurable approval count)
- History tracking for all approval decisions
- Query by task, workflow, status
- Audit trail with decision reasons
- 16 approval tests, all passing
2026-08-23 17:45:44 -07:00

343 lines
8.0 KiB
Go

package approval
import (
"fmt"
"sync"
"time"
)
// ApprovalStatus represents approval state
type ApprovalStatus string
const (
StatusPending ApprovalStatus = "pending"
StatusApproved ApprovalStatus = "approved"
StatusRejected ApprovalStatus = "rejected"
StatusExpired ApprovalStatus = "expired"
)
// ApprovalDecision represents an approval decision
type ApprovalDecision struct {
Status ApprovalStatus `json:"status"`
ApprovedBy string `json:"approved_by"`
RejectedBy string `json:"rejected_by"`
Reason string `json:"reason"`
Comments string `json:"comments"`
Timestamp time.Time `json:"timestamp"`
ExpiresAt time.Time `json:"expires_at"`
}
// ApprovalGate represents a human approval gate
type ApprovalGate struct {
ID string
TaskID string
WorkflowID string
Description string
Decision *ApprovalDecision
CreatedAt time.Time
TTL time.Duration // Time until gate expires
RequiredApprovals int // Number of approvals needed (1 or more)
Approvals []string // List of approvers
}
// ApprovalGateManager manages approval gates
type ApprovalGateManager struct {
mu sync.RWMutex
gates map[string]*ApprovalGate
decisions map[string]*ApprovalDecision
history []*ApprovalRecord
stats *ApprovalStats
}
// ApprovalRecord tracks approval history
type ApprovalRecord struct {
GateID string
Decision ApprovalStatus
ApprovedBy string
RejectedBy string
Timestamp time.Time
Reason string
}
// ApprovalStats tracks approval statistics
type ApprovalStats struct {
TotalGates int
ApprovedGates int
RejectedGates int
PendingGates int
ExpiredGates int
AverageWaitTime time.Duration
}
// NewApprovalGateManager creates a new approval gate manager
func NewApprovalGateManager() *ApprovalGateManager {
return &ApprovalGateManager{
gates: make(map[string]*ApprovalGate),
decisions: make(map[string]*ApprovalDecision),
history: make([]*ApprovalRecord, 0),
stats: &ApprovalStats{
AverageWaitTime: 0,
},
}
}
// CreateGate creates a new approval gate
func (agm *ApprovalGateManager) CreateGate(taskID, workflowID, description string, ttl time.Duration) *ApprovalGate {
if ttl == 0 {
ttl = 24 * time.Hour // Default 24 hours
}
gate := &ApprovalGate{
ID: fmt.Sprintf("gate-%d", time.Now().UnixNano()),
TaskID: taskID,
WorkflowID: workflowID,
Description: description,
CreatedAt: time.Now(),
TTL: ttl,
RequiredApprovals: 1,
Approvals: make([]string, 0),
Decision: &ApprovalDecision{
Status: StatusPending,
ExpiresAt: time.Now().Add(ttl),
},
}
agm.mu.Lock()
defer agm.mu.Unlock()
agm.gates[gate.ID] = gate
agm.decisions[gate.ID] = gate.Decision
agm.stats.TotalGates++
agm.stats.PendingGates++
return gate
}
// ApproveGate approves a gate
func (agm *ApprovalGateManager) ApproveGate(gateID, approvedBy, comments string) error {
agm.mu.Lock()
defer agm.mu.Unlock()
gate, exists := agm.gates[gateID]
if !exists {
return fmt.Errorf("gate not found: %s", gateID)
}
if gate.Decision.Status == StatusApproved || gate.Decision.Status == StatusRejected {
return fmt.Errorf("gate already has a decision: %s", gate.Decision.Status)
}
if time.Now().After(gate.Decision.ExpiresAt) {
gate.Decision.Status = StatusExpired
agm.stats.ExpiredGates++
agm.stats.PendingGates--
return fmt.Errorf("gate has expired")
}
gate.Decision.Status = StatusApproved
gate.Decision.ApprovedBy = approvedBy
gate.Decision.Comments = comments
gate.Decision.Timestamp = time.Now()
gate.Approvals = append(gate.Approvals, approvedBy)
// Record in history
record := &ApprovalRecord{
GateID: gateID,
Decision: StatusApproved,
ApprovedBy: approvedBy,
Timestamp: gate.Decision.Timestamp,
Reason: comments,
}
agm.history = append(agm.history, record)
agm.stats.ApprovedGates++
agm.stats.PendingGates--
return nil
}
// RejectGate rejects a gate
func (agm *ApprovalGateManager) RejectGate(gateID, rejectedBy, reason string) error {
agm.mu.Lock()
defer agm.mu.Unlock()
gate, exists := agm.gates[gateID]
if !exists {
return fmt.Errorf("gate not found: %s", gateID)
}
if gate.Decision.Status == StatusApproved || gate.Decision.Status == StatusRejected {
return fmt.Errorf("gate already has a decision: %s", gate.Decision.Status)
}
if time.Now().After(gate.Decision.ExpiresAt) {
gate.Decision.Status = StatusExpired
agm.stats.ExpiredGates++
agm.stats.PendingGates--
return fmt.Errorf("gate has expired")
}
gate.Decision.Status = StatusRejected
gate.Decision.RejectedBy = rejectedBy
gate.Decision.Reason = reason
gate.Decision.Timestamp = time.Now()
// Record in history
record := &ApprovalRecord{
GateID: gateID,
Decision: StatusRejected,
RejectedBy: rejectedBy,
Timestamp: gate.Decision.Timestamp,
Reason: reason,
}
agm.history = append(agm.history, record)
agm.stats.RejectedGates++
agm.stats.PendingGates--
return nil
}
// GetGate retrieves a gate
func (agm *ApprovalGateManager) GetGate(gateID string) (*ApprovalGate, bool) {
agm.mu.RLock()
defer agm.mu.RUnlock()
gate, exists := agm.gates[gateID]
return gate, exists
}
// GetDecision retrieves a decision
func (agm *ApprovalGateManager) GetDecision(gateID string) (*ApprovalDecision, bool) {
agm.mu.RLock()
defer agm.mu.RUnlock()
decision, exists := agm.decisions[gateID]
return decision, exists
}
// GetPendingGates returns all pending gates
func (agm *ApprovalGateManager) GetPendingGates() []*ApprovalGate {
agm.mu.RLock()
defer agm.mu.RUnlock()
pending := make([]*ApprovalGate, 0)
for _, gate := range agm.gates {
if gate.Decision.Status == StatusPending {
// Check if expired
if time.Now().After(gate.Decision.ExpiresAt) {
gate.Decision.Status = StatusExpired
} else {
pending = append(pending, gate)
}
}
}
return pending
}
// GetGatesByTask returns all gates for a task
func (agm *ApprovalGateManager) GetGatesByTask(taskID string) []*ApprovalGate {
agm.mu.RLock()
defer agm.mu.RUnlock()
gates := make([]*ApprovalGate, 0)
for _, gate := range agm.gates {
if gate.TaskID == taskID {
gates = append(gates, gate)
}
}
return gates
}
// GetGatesByWorkflow returns all gates for a workflow
func (agm *ApprovalGateManager) GetGatesByWorkflow(workflowID string) []*ApprovalGate {
agm.mu.RLock()
defer agm.mu.RUnlock()
gates := make([]*ApprovalGate, 0)
for _, gate := range agm.gates {
if gate.WorkflowID == workflowID {
gates = append(gates, gate)
}
}
return gates
}
// IsApproved checks if a gate is approved
func (agm *ApprovalGateManager) IsApproved(gateID string) bool {
agm.mu.RLock()
defer agm.mu.RUnlock()
gate, exists := agm.gates[gateID]
if !exists {
return false
}
return gate.Decision.Status == StatusApproved
}
// IsRejected checks if a gate is rejected
func (agm *ApprovalGateManager) IsRejected(gateID string) bool {
agm.mu.RLock()
defer agm.mu.RUnlock()
gate, exists := agm.gates[gateID]
if !exists {
return false
}
return gate.Decision.Status == StatusRejected
}
// IsPending checks if a gate is still pending
func (agm *ApprovalGateManager) IsPending(gateID string) bool {
agm.mu.RLock()
defer agm.mu.RUnlock()
gate, exists := agm.gates[gateID]
if !exists {
return false
}
if time.Now().After(gate.Decision.ExpiresAt) {
return false // Expired gates are not pending
}
return gate.Decision.Status == StatusPending
}
// GetStats returns statistics
func (agm *ApprovalGateManager) GetStats() *ApprovalStats {
agm.mu.RLock()
defer agm.mu.RUnlock()
stats := *agm.stats
return &stats
}
// GetHistory returns approval history
func (agm *ApprovalGateManager) GetHistory() []*ApprovalRecord {
agm.mu.RLock()
defer agm.mu.RUnlock()
result := make([]*ApprovalRecord, len(agm.history))
copy(result, agm.history)
return result
}
// Clear clears all gates
func (agm *ApprovalGateManager) Clear() {
agm.mu.Lock()
defer agm.mu.Unlock()
agm.gates = make(map[string]*ApprovalGate)
agm.decisions = make(map[string]*ApprovalDecision)
agm.history = make([]*ApprovalRecord, 0)
agm.stats = &ApprovalStats{}
}