project-standalo-sonic-cloud/skills/guardrail-orchestrator/scripts/workflow_manager.py

836 lines
29 KiB
Python
Executable File

#!/usr/bin/env python3
"""Workflow state management for automated orchestration with approval gates."""
import argparse
import json
import os
import shutil
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
# Try to import yaml, fall back to basic parsing if not available
try:
import yaml
HAS_YAML = True
except ImportError:
HAS_YAML = False
# ============================================================================
# YAML Helpers
# ============================================================================
def load_yaml(filepath: str) -> dict:
"""Load YAML file."""
if not os.path.exists(filepath):
return {}
with open(filepath, 'r') as f:
content = f.read()
if not content.strip():
return {}
if HAS_YAML:
return yaml.safe_load(content) or {}
# Simple fallback parser
result = {}
current_key = None
current_list = None
for line in content.split('\n'):
line = line.rstrip()
if not line or line.startswith('#'):
continue
if line.startswith(' - '):
if current_list is not None:
value = line[4:].strip()
# Handle quoted strings
if (value.startswith('"') and value.endswith('"')) or \
(value.startswith("'") and value.endswith("'")):
value = value[1:-1]
current_list.append(value)
continue
if ':' in line and not line.startswith(' '):
key, _, value = line.partition(':')
key = key.strip()
value = value.strip()
if value == '[]':
result[key] = []
current_list = result[key]
elif value == '{}':
result[key] = {}
current_list = None
elif value == 'null' or value == '~':
result[key] = None
current_list = None
elif value == 'true':
result[key] = True
current_list = None
elif value == 'false':
result[key] = False
current_list = None
elif value.isdigit():
result[key] = int(value)
current_list = None
elif value:
# Handle quoted strings
if (value.startswith('"') and value.endswith('"')) or \
(value.startswith("'") and value.endswith("'")):
value = value[1:-1]
result[key] = value
current_list = None
else:
result[key] = []
current_list = result[key]
current_key = key
return result
def save_yaml(filepath: str, data: dict):
"""Save data to YAML file."""
os.makedirs(os.path.dirname(filepath), exist_ok=True)
if HAS_YAML:
with open(filepath, 'w') as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
else:
# Simple YAML writer
def write_value(value, indent=0):
prefix = ' ' * indent
lines = []
if isinstance(value, dict):
for k, v in value.items():
if isinstance(v, (dict, list)) and v:
lines.append(f"{prefix}{k}:")
lines.extend(write_value(v, indent + 1))
elif isinstance(v, list):
lines.append(f"{prefix}{k}: []")
else:
lines.append(f"{prefix}{k}: {v}")
elif isinstance(value, list):
for item in value:
if isinstance(item, dict):
lines.append(f"{prefix}-")
for k, v in item.items():
lines.append(f"{prefix} {k}: {v}")
else:
lines.append(f"{prefix}- {item}")
return lines
lines = write_value(data)
with open(filepath, 'w') as f:
f.write('\n'.join(lines))
# ============================================================================
# Workflow State Management
# ============================================================================
PHASES = [
'INITIALIZING',
'DESIGNING',
'AWAITING_DESIGN_APPROVAL',
'DESIGN_APPROVED',
'DESIGN_REJECTED',
'IMPLEMENTING',
'REVIEWING',
'SECURITY_REVIEW', # New phase for security audit
'AWAITING_IMPL_APPROVAL',
'IMPL_APPROVED',
'IMPL_REJECTED',
'COMPLETING',
'COMPLETED',
'PAUSED',
'FAILED'
]
VALID_TRANSITIONS = {
'INITIALIZING': ['DESIGNING', 'FAILED'],
'DESIGNING': ['AWAITING_DESIGN_APPROVAL', 'FAILED'],
'AWAITING_DESIGN_APPROVAL': ['DESIGN_APPROVED', 'DESIGN_REJECTED', 'PAUSED'],
'DESIGN_APPROVED': ['IMPLEMENTING', 'FAILED'],
'DESIGN_REJECTED': ['DESIGNING'],
'IMPLEMENTING': ['REVIEWING', 'FAILED', 'PAUSED'],
'REVIEWING': ['SECURITY_REVIEW', 'IMPLEMENTING', 'FAILED'], # Must pass through security
'SECURITY_REVIEW': ['AWAITING_IMPL_APPROVAL', 'IMPLEMENTING', 'FAILED'], # Can go back to fix
'AWAITING_IMPL_APPROVAL': ['IMPL_APPROVED', 'IMPL_REJECTED', 'PAUSED'],
'IMPL_APPROVED': ['COMPLETING', 'FAILED'],
'IMPL_REJECTED': ['IMPLEMENTING'],
'COMPLETING': ['COMPLETED', 'FAILED'],
'COMPLETED': [],
'PAUSED': PHASES, # Can resume to any phase
'FAILED': ['INITIALIZING', 'DESIGNING', 'IMPLEMENTING'] # Can retry
}
def get_workflow_dir() -> Path:
"""Get the .workflow directory path."""
return Path('.workflow')
def get_current_state_path() -> Path:
"""Get the current workflow state file path."""
return get_workflow_dir() / 'current.yml'
def get_history_dir() -> Path:
"""Get the workflow history directory."""
return get_workflow_dir() / 'history'
def create_workflow(feature: str) -> dict:
"""Create a new workflow state."""
now = datetime.now()
workflow_id = f"workflow_{now.strftime('%Y%m%d_%H%M%S')}"
state = {
'id': workflow_id,
'feature': feature,
'current_phase': 'INITIALIZING',
'gates': {
'design_approval': {
'status': 'pending',
'approved_at': None,
'approved_by': None,
'rejection_reason': None,
'revision_count': 0
},
'implementation_approval': {
'status': 'pending',
'approved_at': None,
'approved_by': None,
'rejection_reason': None,
'revision_count': 0
}
},
'progress': {
'entities_designed': 0,
'tasks_created': 0,
'tasks_implemented': 0,
'tasks_reviewed': 0,
'tasks_approved': 0,
'tasks_completed': 0
},
'tasks': {
'pending': [],
'in_progress': [],
'review': [],
'approved': [],
'completed': [],
'blocked': []
},
'started_at': now.isoformat(),
'updated_at': now.isoformat(),
'completed_at': None,
'last_error': None,
'resume_point': {
'phase': 'INITIALIZING',
'task_id': None,
'action': 'start_workflow'
},
'checkpoints': [] # List of checkpoint snapshots for recovery
}
# Ensure directory exists
get_workflow_dir().mkdir(exist_ok=True)
get_history_dir().mkdir(exist_ok=True)
# Save state
save_yaml(str(get_current_state_path()), state)
return state
def load_current_workflow() -> Optional[dict]:
"""Load the current workflow state from the active version."""
state_path = get_current_state_path()
if not state_path.exists():
return None
# Read current.yml to get active version
current = load_yaml(str(state_path))
active_version = current.get('active_version')
if not active_version:
return None
# Load the version's session.yml
version_session_path = get_workflow_dir() / 'versions' / active_version / 'session.yml'
if not version_session_path.exists():
return None
session = load_yaml(str(version_session_path))
current_phase = session.get('current_phase', 'INITIALIZING')
# Convert session format to state format expected by show_status
return {
'id': session.get('session_id', active_version),
'feature': session.get('feature', 'Unknown'),
'current_phase': current_phase,
'gates': {
'design_approval': session.get('approvals', {}).get('design', {'status': 'pending'}),
'implementation_approval': session.get('approvals', {}).get('implementation', {'status': 'pending'})
},
'progress': {
'entities_designed': session.get('summary', {}).get('entities_created', 0),
'tasks_created': session.get('summary', {}).get('total_tasks', 0),
'tasks_implemented': session.get('summary', {}).get('tasks_completed', 0),
'tasks_reviewed': 0,
'tasks_completed': session.get('summary', {}).get('tasks_completed', 0)
},
'tasks': {
'pending': [],
'in_progress': [],
'review': [],
'approved': [],
'completed': session.get('task_sessions', []),
'blocked': []
},
'version': active_version,
'status': session.get('status', 'unknown'),
'last_error': None,
'started_at': session.get('started_at', ''),
'updated_at': session.get('updated_at', ''),
'completed_at': session.get('completed_at'),
'resume_point': {
'phase': current_phase,
'task_id': None,
'action': 'continue_workflow'
}
}
def save_workflow(state: dict):
"""Save workflow state to the version's session.yml file."""
# Get active version
current_path = get_current_state_path()
if not current_path.exists():
print("Error: No current.yml found")
return
current = load_yaml(str(current_path))
active_version = current.get('active_version')
if not active_version:
print("Error: No active version set")
return
# Get the version's session.yml path
version_session_path = get_workflow_dir() / 'versions' / active_version / 'session.yml'
if not version_session_path.exists():
print(f"Error: Session file not found: {version_session_path}")
return
# Load existing session data
session = load_yaml(str(version_session_path))
# Create backup
backup_path = version_session_path.with_suffix('.yml.bak')
shutil.copy(version_session_path, backup_path)
# Update session with state changes
session['current_phase'] = state['current_phase']
session['updated_at'] = datetime.now().isoformat()
if state.get('completed_at'):
session['completed_at'] = state['completed_at']
session['status'] = 'completed'
# Update approvals
if 'gates' in state:
if 'approvals' not in session:
session['approvals'] = {}
if state['gates'].get('design_approval', {}).get('status') == 'approved':
session['approvals']['design'] = state['gates']['design_approval']
if state['gates'].get('implementation_approval', {}).get('status') == 'approved':
session['approvals']['implementation'] = state['gates']['implementation_approval']
save_yaml(str(version_session_path), session)
def transition_phase(state: dict, new_phase: str, error: str = None) -> bool:
"""Transition workflow to a new phase."""
current = state['current_phase']
if new_phase not in PHASES:
print(f"Error: Invalid phase '{new_phase}'")
return False
if new_phase not in VALID_TRANSITIONS.get(current, []):
print(f"Error: Cannot transition from '{current}' to '{new_phase}'")
print(f"Valid transitions: {VALID_TRANSITIONS.get(current, [])}")
return False
state['current_phase'] = new_phase
state['resume_point']['phase'] = new_phase
if new_phase == 'FAILED' and error:
state['last_error'] = error
if new_phase == 'COMPLETED':
state['completed_at'] = datetime.now().isoformat()
# Set appropriate resume action
resume_actions = {
'INITIALIZING': 'start_workflow',
'DESIGNING': 'continue_design',
'AWAITING_DESIGN_APPROVAL': 'await_user_approval',
'DESIGN_APPROVED': 'start_implementation',
'DESIGN_REJECTED': 'revise_design',
'IMPLEMENTING': 'continue_implementation',
'REVIEWING': 'continue_review',
'SECURITY_REVIEW': 'run_security_audit',
'AWAITING_IMPL_APPROVAL': 'await_user_approval',
'IMPL_APPROVED': 'start_completion',
'IMPL_REJECTED': 'fix_implementation',
'COMPLETING': 'continue_completion',
'COMPLETED': 'workflow_done',
'PAUSED': 'resume_workflow',
'FAILED': 'retry_or_abort'
}
state['resume_point']['action'] = resume_actions.get(new_phase, 'unknown')
save_workflow(state)
return True
def approve_gate(state: dict, gate: str, approver: str = 'user') -> bool:
"""Approve a gate."""
if gate not in ['design_approval', 'implementation_approval']:
print(f"Error: Invalid gate '{gate}'")
return False
state['gates'][gate]['status'] = 'approved'
state['gates'][gate]['approved_at'] = datetime.now().isoformat()
state['gates'][gate]['approved_by'] = approver
# Transition to next phase
if gate == 'design_approval':
transition_phase(state, 'DESIGN_APPROVED')
else:
transition_phase(state, 'IMPL_APPROVED')
return True
def reject_gate(state: dict, gate: str, reason: str) -> bool:
"""Reject a gate."""
if gate not in ['design_approval', 'implementation_approval']:
print(f"Error: Invalid gate '{gate}'")
return False
state['gates'][gate]['status'] = 'rejected'
state['gates'][gate]['rejection_reason'] = reason
state['gates'][gate]['revision_count'] += 1
# Transition to rejection phase
if gate == 'design_approval':
transition_phase(state, 'DESIGN_REJECTED')
else:
transition_phase(state, 'IMPL_REJECTED')
return True
def update_progress(state: dict, **kwargs):
"""Update progress counters."""
for key, value in kwargs.items():
if key in state['progress']:
state['progress'][key] = value
save_workflow(state)
def update_task_status(state: dict, task_id: str, new_status: str):
"""Update task status in workflow state."""
# Remove from all status lists
for status in state['tasks']:
if task_id in state['tasks'][status]:
state['tasks'][status].remove(task_id)
# Add to new status list
if new_status in state['tasks']:
state['tasks'][new_status].append(task_id)
# Update resume point if task is in progress
if new_status == 'in_progress':
state['resume_point']['task_id'] = task_id
save_workflow(state)
def save_checkpoint(state: dict, description: str, data: dict = None) -> dict:
"""Save a checkpoint for recovery during long operations.
Args:
state: Current workflow state
description: Human-readable description of checkpoint
data: Optional additional data to store
Returns:
The checkpoint object that was created
"""
checkpoint = {
'id': f"checkpoint_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
'timestamp': datetime.now().isoformat(),
'phase': state['current_phase'],
'description': description,
'resume_point': state['resume_point'].copy(),
'progress': state['progress'].copy(),
'data': data or {}
}
# Keep only last 10 checkpoints to avoid bloat
if 'checkpoints' not in state:
state['checkpoints'] = []
state['checkpoints'].append(checkpoint)
if len(state['checkpoints']) > 10:
state['checkpoints'] = state['checkpoints'][-10:]
save_workflow(state)
return checkpoint
def get_latest_checkpoint(state: dict) -> Optional[dict]:
"""Get the most recent checkpoint.
Returns:
Latest checkpoint or None if no checkpoints exist
"""
checkpoints = state.get('checkpoints', [])
return checkpoints[-1] if checkpoints else None
def restore_from_checkpoint(state: dict, checkpoint_id: str = None) -> bool:
"""Restore workflow state from a checkpoint.
Args:
state: Current workflow state
checkpoint_id: Optional specific checkpoint ID, defaults to latest
Returns:
True if restoration was successful
"""
checkpoints = state.get('checkpoints', [])
if not checkpoints:
print("Error: No checkpoints available")
return False
# Find checkpoint
if checkpoint_id:
checkpoint = next((c for c in checkpoints if c['id'] == checkpoint_id), None)
if not checkpoint:
print(f"Error: Checkpoint '{checkpoint_id}' not found")
return False
else:
checkpoint = checkpoints[-1]
# Restore state from checkpoint
state['resume_point'] = checkpoint['resume_point'].copy()
state['progress'] = checkpoint['progress'].copy()
state['current_phase'] = checkpoint['phase']
state['last_error'] = None # Clear any error since we're recovering
save_workflow(state)
print(f"Restored from checkpoint: {checkpoint['description']}")
return True
def list_checkpoints(state: dict) -> list:
"""List all available checkpoints.
Returns:
List of checkpoint summaries
"""
return [
{
'id': c['id'],
'timestamp': c['timestamp'],
'phase': c['phase'],
'description': c['description']
}
for c in state.get('checkpoints', [])
]
def clear_checkpoints(state: dict):
"""Clear all checkpoints (typically after successful completion)."""
state['checkpoints'] = []
save_workflow(state)
def archive_workflow(state: dict, suffix: str = ''):
"""Archive completed/aborted workflow."""
history_dir = get_history_dir()
history_dir.mkdir(exist_ok=True)
filename = f"{state['id']}{suffix}.yml"
archive_path = history_dir / filename
save_yaml(str(archive_path), state)
# Remove current state
current_path = get_current_state_path()
if current_path.exists():
current_path.unlink()
def show_status(state: dict):
"""Display workflow status."""
print()
print("" + "" * 58 + "")
print("" + "WORKFLOW STATUS".center(58) + "")
print("" + "" * 58 + "")
print("" + f" ID: {state['id']}".ljust(58) + "")
print("" + f" Feature: {state['feature'][:45]}".ljust(58) + "")
print("" + f" Phase: {state['current_phase']}".ljust(58) + "")
print("" + "" * 58 + "")
print("" + " APPROVAL GATES".ljust(58) + "")
design_gate = state['gates']['design_approval']
impl_gate = state['gates']['implementation_approval']
design_icon = "" if design_gate['status'] == 'approved' else "" if design_gate['status'] == 'rejected' else ""
impl_icon = "" if impl_gate['status'] == 'approved' else "" if impl_gate['status'] == 'rejected' else ""
print("" + f" {design_icon} Design: {design_gate['status']}".ljust(58) + "")
print("" + f" {impl_icon} Implementation: {impl_gate['status']}".ljust(58) + "")
print("" + "" * 58 + "")
print("" + " PROGRESS".ljust(58) + "")
p = state['progress']
print("" + f" Entities Designed: {p['entities_designed']}".ljust(58) + "")
print("" + f" Tasks Created: {p['tasks_created']}".ljust(58) + "")
print("" + f" Tasks Implemented: {p['tasks_implemented']}".ljust(58) + "")
print("" + f" Tasks Reviewed: {p['tasks_reviewed']}".ljust(58) + "")
print("" + f" Tasks Completed: {p['tasks_completed']}".ljust(58) + "")
print("" + "" * 58 + "")
print("" + " TASK BREAKDOWN".ljust(58) + "")
t = state['tasks']
print("" + f" ⏳ Pending: {len(t['pending'])}".ljust(58) + "")
print("" + f" 🔄 In Progress: {len(t['in_progress'])}".ljust(58) + "")
print("" + f" 🔍 Review: {len(t['review'])}".ljust(58) + "")
print("" + f" ✅ Approved: {len(t['approved'])}".ljust(58) + "")
print("" + f" ✓ Completed: {len(t['completed'])}".ljust(58) + "")
print("" + f" 🚫 Blocked: {len(t['blocked'])}".ljust(58) + "")
if state['last_error']:
print("" + "" * 58 + "")
print("" + " ⚠️ LAST ERROR".ljust(58) + "")
print("" + f" {state['last_error'][:52]}".ljust(58) + "")
print("" + "" * 58 + "")
print("" + " TIMESTAMPS".ljust(58) + "")
print("" + f" Started: {state['started_at'][:19]}".ljust(58) + "")
print("" + f" Updated: {state['updated_at'][:19]}".ljust(58) + "")
if state['completed_at']:
print("" + f" Completed: {state['completed_at'][:19]}".ljust(58) + "")
print("" + "" * 58 + "")
# ============================================================================
# CLI Interface
# ============================================================================
def main():
parser = argparse.ArgumentParser(description="Workflow state management")
subparsers = parser.add_subparsers(dest='command', help='Commands')
# create command
create_parser = subparsers.add_parser('create', help='Create new workflow')
create_parser.add_argument('feature', help='Feature to implement')
# status command
subparsers.add_parser('status', help='Show workflow status')
# transition command
trans_parser = subparsers.add_parser('transition', help='Transition to new phase')
trans_parser.add_argument('phase', choices=PHASES, help='Target phase')
trans_parser.add_argument('--error', help='Error message (for FAILED phase)')
# approve command
approve_parser = subparsers.add_parser('approve', help='Approve a gate')
approve_parser.add_argument('gate', choices=['design', 'implementation'], help='Gate to approve')
approve_parser.add_argument('--approver', default='user', help='Approver name')
# reject command
reject_parser = subparsers.add_parser('reject', help='Reject a gate')
reject_parser.add_argument('gate', choices=['design', 'implementation'], help='Gate to reject')
reject_parser.add_argument('reason', help='Rejection reason')
# progress command
progress_parser = subparsers.add_parser('progress', help='Update progress')
progress_parser.add_argument('--entities', type=int, help='Entities designed')
progress_parser.add_argument('--tasks-created', type=int, help='Tasks created')
progress_parser.add_argument('--tasks-impl', type=int, help='Tasks implemented')
progress_parser.add_argument('--tasks-reviewed', type=int, help='Tasks reviewed')
progress_parser.add_argument('--tasks-completed', type=int, help='Tasks completed')
# task command
task_parser = subparsers.add_parser('task', help='Update task status')
task_parser.add_argument('task_id', help='Task ID')
task_parser.add_argument('status', choices=['pending', 'in_progress', 'review', 'approved', 'completed', 'blocked'])
# archive command
archive_parser = subparsers.add_parser('archive', help='Archive workflow')
archive_parser.add_argument('--suffix', default='', help='Filename suffix (e.g., _aborted)')
# exists command
subparsers.add_parser('exists', help='Check if workflow exists')
# checkpoint command
checkpoint_parser = subparsers.add_parser('checkpoint', help='Manage checkpoints')
checkpoint_parser.add_argument('action', choices=['save', 'list', 'restore', 'clear'],
help='Checkpoint action')
checkpoint_parser.add_argument('--description', '-d', help='Checkpoint description (for save)')
checkpoint_parser.add_argument('--id', help='Checkpoint ID (for restore)')
checkpoint_parser.add_argument('--data', help='JSON data to store (for save)')
args = parser.parse_args()
if args.command == 'create':
state = create_workflow(args.feature)
print(f"Created workflow: {state['id']}")
print(f"Feature: {args.feature}")
print(f"State saved to: {get_current_state_path()}")
elif args.command == 'status':
state = load_current_workflow()
if state:
show_status(state)
else:
print("No active workflow found.")
print("Start a new workflow with: /workflow:spawn <feature>")
elif args.command == 'transition':
state = load_current_workflow()
if not state:
print("Error: No active workflow")
sys.exit(1)
if transition_phase(state, args.phase, args.error):
print(f"Transitioned to: {args.phase}")
else:
sys.exit(1)
elif args.command == 'approve':
state = load_current_workflow()
if not state:
print("Error: No active workflow")
sys.exit(1)
gate = f"{args.gate}_approval"
if approve_gate(state, gate, args.approver):
print(f"Approved: {args.gate}")
elif args.command == 'reject':
state = load_current_workflow()
if not state:
print("Error: No active workflow")
sys.exit(1)
gate = f"{args.gate}_approval"
if reject_gate(state, gate, args.reason):
print(f"Rejected: {args.gate}")
print(f"Reason: {args.reason}")
elif args.command == 'progress':
state = load_current_workflow()
if not state:
print("Error: No active workflow")
sys.exit(1)
updates = {}
if args.entities is not None:
updates['entities_designed'] = args.entities
if args.tasks_created is not None:
updates['tasks_created'] = args.tasks_created
if args.tasks_impl is not None:
updates['tasks_implemented'] = args.tasks_impl
if args.tasks_reviewed is not None:
updates['tasks_reviewed'] = args.tasks_reviewed
if args.tasks_completed is not None:
updates['tasks_completed'] = args.tasks_completed
if updates:
update_progress(state, **updates)
print("Progress updated")
elif args.command == 'task':
state = load_current_workflow()
if not state:
print("Error: No active workflow")
sys.exit(1)
update_task_status(state, args.task_id, args.status)
print(f"Task {args.task_id}{args.status}")
elif args.command == 'archive':
state = load_current_workflow()
if not state:
print("Error: No active workflow")
sys.exit(1)
archive_workflow(state, args.suffix)
print(f"Workflow archived to: {get_history_dir()}/{state['id']}{args.suffix}.yml")
elif args.command == 'exists':
state = load_current_workflow()
if state:
print("true")
sys.exit(0)
else:
print("false")
sys.exit(1)
elif args.command == 'checkpoint':
state = load_current_workflow()
if not state:
print("Error: No active workflow")
sys.exit(1)
if args.action == 'save':
if not args.description:
print("Error: --description required for save")
sys.exit(1)
data = None
if args.data:
try:
data = json.loads(args.data)
except json.JSONDecodeError:
print("Error: --data must be valid JSON")
sys.exit(1)
checkpoint = save_checkpoint(state, args.description, data)
print(f"Checkpoint saved: {checkpoint['id']}")
print(f"Description: {args.description}")
elif args.action == 'list':
checkpoints = list_checkpoints(state)
if not checkpoints:
print("No checkpoints available")
else:
print("\n" + "=" * 60)
print("CHECKPOINTS".center(60))
print("=" * 60)
for cp in checkpoints:
print(f"\n ID: {cp['id']}")
print(f" Time: {cp['timestamp'][:19]}")
print(f" Phase: {cp['phase']}")
print(f" Description: {cp['description']}")
print("\n" + "=" * 60)
elif args.action == 'restore':
if restore_from_checkpoint(state, args.id):
print("Workflow state restored successfully")
else:
sys.exit(1)
elif args.action == 'clear':
clear_checkpoints(state)
print("All checkpoints cleared")
else:
parser.print_help()
if __name__ == "__main__":
main()