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
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
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{}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewApprovalGateManager(t *testing.T) {
|
||||
manager := NewApprovalGateManager()
|
||||
assert.NotNil(t, manager)
|
||||
assert.Equal(t, 0, manager.stats.TotalGates)
|
||||
}
|
||||
|
||||
func TestCreateGate(t *testing.T) {
|
||||
manager := NewApprovalGateManager()
|
||||
gate := manager.CreateGate("T0.1", "workflow-1", "Review", 24*time.Hour)
|
||||
assert.NotNil(t, gate)
|
||||
assert.Equal(t, "T0.1", gate.TaskID)
|
||||
assert.Equal(t, StatusPending, gate.Decision.Status)
|
||||
}
|
||||
|
||||
func TestApproveGate(t *testing.T) {
|
||||
manager := NewApprovalGateManager()
|
||||
gate := manager.CreateGate("T0.1", "workflow-1", "Review", 24*time.Hour)
|
||||
err := manager.ApproveGate(gate.ID, "reviewer-1", "Approved")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, manager.IsApproved(gate.ID))
|
||||
}
|
||||
|
||||
func TestRejectGate(t *testing.T) {
|
||||
manager := NewApprovalGateManager()
|
||||
gate := manager.CreateGate("T0.1", "workflow-1", "Review", 24*time.Hour)
|
||||
err := manager.RejectGate(gate.ID, "reviewer-1", "Rejected")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, manager.IsRejected(gate.ID))
|
||||
}
|
||||
|
||||
func TestGetPendingGates(t *testing.T) {
|
||||
manager := NewApprovalGateManager()
|
||||
gate1 := manager.CreateGate("T0.1", "workflow-1", "Review", 24*time.Hour)
|
||||
gate2 := manager.CreateGate("T0.2", "workflow-1", "Review", 24*time.Hour)
|
||||
|
||||
manager.ApproveGate(gate1.ID, "reviewer-1", "")
|
||||
|
||||
pending := manager.GetPendingGates()
|
||||
assert.Equal(t, 1, len(pending))
|
||||
if len(pending) > 0 {
|
||||
assert.Equal(t, gate2.ID, pending[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGatesByTask(t *testing.T) {
|
||||
manager := NewApprovalGateManager()
|
||||
manager.CreateGate("T0.1", "workflow-1", "Review", 24*time.Hour)
|
||||
manager.CreateGate("T0.2", "workflow-1", "Review", 24*time.Hour)
|
||||
|
||||
gates := manager.GetGatesByTask("T0.1")
|
||||
assert.Greater(t, len(gates), 0)
|
||||
}
|
||||
|
||||
func TestGetGatesByWorkflow(t *testing.T) {
|
||||
manager := NewApprovalGateManager()
|
||||
manager.CreateGate("T0.1", "workflow-1", "Review", 24*time.Hour)
|
||||
manager.CreateGate("T0.2", "workflow-2", "Review", 24*time.Hour)
|
||||
|
||||
gates := manager.GetGatesByWorkflow("workflow-1")
|
||||
assert.Greater(t, len(gates), 0)
|
||||
}
|
||||
|
||||
func TestIsApproved(t *testing.T) {
|
||||
manager := NewApprovalGateManager()
|
||||
gate := manager.CreateGate("T0.1", "workflow-1", "Review", 24*time.Hour)
|
||||
assert.False(t, manager.IsApproved(gate.ID))
|
||||
|
||||
manager.ApproveGate(gate.ID, "reviewer-1", "")
|
||||
assert.True(t, manager.IsApproved(gate.ID))
|
||||
}
|
||||
|
||||
func TestIsRejected(t *testing.T) {
|
||||
manager := NewApprovalGateManager()
|
||||
gate := manager.CreateGate("T0.1", "workflow-1", "Review", 24*time.Hour)
|
||||
assert.False(t, manager.IsRejected(gate.ID))
|
||||
|
||||
manager.RejectGate(gate.ID, "reviewer-1", "")
|
||||
assert.True(t, manager.IsRejected(gate.ID))
|
||||
}
|
||||
|
||||
func TestIsPending(t *testing.T) {
|
||||
manager := NewApprovalGateManager()
|
||||
gate := manager.CreateGate("T0.1", "workflow-1", "Review", 24*time.Hour)
|
||||
assert.True(t, manager.IsPending(gate.ID))
|
||||
|
||||
manager.ApproveGate(gate.ID, "reviewer-1", "")
|
||||
assert.False(t, manager.IsPending(gate.ID))
|
||||
}
|
||||
|
||||
func TestGetStats(t *testing.T) {
|
||||
manager := NewApprovalGateManager()
|
||||
gate1 := manager.CreateGate("T0.1", "workflow-1", "Review", 24*time.Hour)
|
||||
_ = manager.CreateGate("T0.2", "workflow-1", "Review", 24*time.Hour)
|
||||
|
||||
manager.ApproveGate(gate1.ID, "reviewer-1", "")
|
||||
|
||||
stats := manager.GetStats()
|
||||
assert.Equal(t, 2, stats.TotalGates)
|
||||
assert.Equal(t, 1, stats.ApprovedGates)
|
||||
assert.Equal(t, 1, stats.PendingGates)
|
||||
}
|
||||
|
||||
func TestGetHistory(t *testing.T) {
|
||||
manager := NewApprovalGateManager()
|
||||
gate1 := manager.CreateGate("T0.1", "workflow-1", "Review", 24*time.Hour)
|
||||
gate2 := manager.CreateGate("T0.2", "workflow-1", "Review", 24*time.Hour)
|
||||
|
||||
manager.ApproveGate(gate1.ID, "reviewer-1", "")
|
||||
manager.RejectGate(gate2.ID, "reviewer-2", "Needs work")
|
||||
|
||||
history := manager.GetHistory()
|
||||
assert.Greater(t, len(history), 0)
|
||||
}
|
||||
|
||||
func TestClear(t *testing.T) {
|
||||
manager := NewApprovalGateManager()
|
||||
manager.CreateGate("T0.1", "workflow-1", "Review", 24*time.Hour)
|
||||
assert.Equal(t, 1, manager.stats.TotalGates)
|
||||
|
||||
manager.Clear()
|
||||
assert.Equal(t, 0, manager.stats.TotalGates)
|
||||
}
|
||||
|
||||
func TestExpiredGate(t *testing.T) {
|
||||
manager := NewApprovalGateManager()
|
||||
gate := manager.CreateGate("T0.1", "workflow-1", "Review", 1*time.Millisecond)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
err := manager.ApproveGate(gate.ID, "reviewer-1", "")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMultipleApprovals(t *testing.T) {
|
||||
manager := NewApprovalGateManager()
|
||||
gate := manager.CreateGate("T0.1", "workflow-1", "Review", 24*time.Hour)
|
||||
gate.RequiredApprovals = 2
|
||||
|
||||
err := manager.ApproveGate(gate.ID, "reviewer-1", "")
|
||||
assert.NoError(t, err)
|
||||
|
||||
retrieved, _ := manager.GetGate(gate.ID)
|
||||
assert.Equal(t, 1, len(retrieved.Approvals))
|
||||
}
|
||||
|
||||
func BenchmarkCreateGate(b *testing.B) {
|
||||
manager := NewApprovalGateManager()
|
||||
for i := 0; i < b.N; i++ {
|
||||
manager.CreateGate("T0.1", "workflow-1", "Review", 24*time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkApproveGate(b *testing.B) {
|
||||
manager := NewApprovalGateManager()
|
||||
gates := make([]*ApprovalGate, b.N)
|
||||
for i := 0; i < b.N; i++ {
|
||||
gates[i] = manager.CreateGate("T0.1", "workflow-1", "Review", 24*time.Hour)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
manager.ApproveGate(gates[i].ID, "reviewer-1", "")
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
| T3.1 | Custom skill plugins: load user-defined skills from plugin registry (not just pi clone) | [x] | `task/T3.1` | Custom skill plugin loads, PrepareSkillsActivity calls plugin:// URLs |
|
||||
| T3.2 | Workflow templates: save/load orchestrator config as YAML templates (not CLI flags only) | [x] | `task/T3.2` | Load template `templates/golang-project.yaml` → workflow configures Planner/Judge/Implementer for Go projects |
|
||||
| T3.3 | Task dependency graph: specify task order (T0.2 must complete before T0.3 can start) | [x] | `task/T3.3` | Board supports `depends_on: [T0.1]` field, orchestrator respects ordering |
|
||||
| T3.4 | Human-in-the-loop gates: pause workflow, require approval before proceeding to next task | [ ] | `task/T3.4` | Workflow waits for `approve-task` signal, Judge verdict is final (can't auto-retry after user approval) |
|
||||
| T3.4 | Human-in-the-loop gates: pause workflow, require approval before proceeding to next task | [x] | `task/T3.4` | Workflow waits for `approve-task` signal, Judge verdict is final (can't auto-retry after user approval) |
|
||||
| T3.5 | Custom Judge implementations: swap default Judge for domain-specific validator (e.g., security auditor) | [ ] | `task/T3.5` | Register custom JudgeActivity, orchestrator uses it instead of default |
|
||||
| T3.6 | Immutable audit trail: all Planner/Judge/Implementer decisions written to tamper-proof log | [ ] | `task/T3.6` | Audit log signed with per-workflow key, verification prevents tampering |
|
||||
| T3.7 | Workflow composition: nest OrchestratorWorkflows (one orchestrator dispatches child orchestrators) | [ ] | `task/T3.7` | Multi-level task hierarchy: T0 milestone → T0.a/T0.b sub-milestones, each with own orchestrator |
|
||||
|
||||
Reference in New Issue
Block a user