- 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
343 lines
8.0 KiB
Go
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{}
|
|
}
|