""" Cleanup script for removing stale tool references from project sets. This script identifies and removes tools that exist in project_tools:{project_id} sets but don't exist in the approved_tools hash. This can happen when tools were deleted before the comprehensive cleanup logic was implemented. Usage: python scripts/cleanup_stale_tools.py [--user-id USER_ID] [--dry-run] """ import asyncio import argparse import sys import os # Add parent directory to path sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) from app.core.redis import get_redis_client from app.core.redis_user_aware import ( get_namespaced_key, get_all_approved_tools, get_all_projects, get_project_tools, emit_tools_changed_notification ) from app.core.logging import get_logger logger = get_logger(__name__) async def cleanup_stale_tools(user_id: str = None, dry_run: bool = True): """ Remove stale tool references from project sets. Args: user_id: Optional user ID for user-scoped cleanup dry_run: If True, only report what would be cleaned up without making changes """ redis = await get_redis_client() try: # Get all approved tools (source of truth) approved_tools = await get_all_approved_tools(redis, user_id=user_id) approved_tool_names = set(approved_tools.keys()) logger.info(f"Found {len(approved_tool_names)} approved tools") # Get all projects projects = await get_all_projects(redis, user_id=user_id) logger.info(f"Found {len(projects)} projects to check") total_stale = 0 total_cleaned = 0 # Check each project's tools for project in projects: project_id = project.get("id") logger.info(f"\nChecking project: {project_id}") # Get tools in this project project_tools = await get_project_tools(redis, project_id, user_id=user_id) logger.info(f" Project has {len(project_tools)} tools: {project_tools}") # Find stale tools (in project but not in approved_tools) stale_tools = [t for t in project_tools if t not in approved_tool_names] if stale_tools: total_stale += len(stale_tools) logger.warning(f" Found {len(stale_tools)} STALE tools: {stale_tools}") if not dry_run: # Remove stale tools from project set tools_key = get_namespaced_key(user_id, f"project_tools:{project_id}") if user_id else f"project_tools:{project_id}" for stale_tool in stale_tools: await redis.srem(tools_key, stale_tool) logger.info(f" REMOVED stale tool: {stale_tool}") total_cleaned += 1 # Also clean up tool_projects mapping tool_projects_key = get_namespaced_key(user_id, "tool_projects") if user_id else "tool_projects" for stale_tool in stale_tools: await redis.hdel(tool_projects_key, stale_tool) logger.info(f" REMOVED from tool_projects mapping: {stale_tool}") else: logger.info(f" [DRY RUN] Would remove: {stale_tools}") else: logger.info(f" ✓ No stale tools found") # Summary print("\n" + "="*60) if dry_run: print(f"DRY RUN COMPLETE") print(f"Found {total_stale} stale tool references across {len(projects)} projects") print(f"Run without --dry-run to clean up") else: print(f"CLEANUP COMPLETE") print(f"Removed {total_cleaned} stale tool references") # Emit tools changed notification if we cleaned anything if total_cleaned > 0: await emit_tools_changed_notification(redis, user_id=user_id) logger.info("Emitted tools_changed notification") print("="*60) finally: await redis.close() async def main(): parser = argparse.ArgumentParser(description="Clean up stale tool references from Redis") parser.add_argument( "--user-id", type=str, help="User ID for user-scoped cleanup (e.g., google_114049623254424116299)" ) parser.add_argument( "--dry-run", action="store_true", help="Only report what would be cleaned up without making changes" ) args = parser.parse_args() print("Guardian Forge - Stale Tool Cleanup Utility") print("="*60) print(f"User ID: {args.user_id or 'global'}") print(f"Mode: {'DRY RUN' if args.dry_run else 'LIVE CLEANUP'}") print("="*60 + "\n") if not args.dry_run: confirm = input("This will modify Redis data. Continue? (yes/no): ") if confirm.lower() != "yes": print("Cancelled.") return await cleanup_stale_tools(user_id=args.user_id, dry_run=args.dry_run) if __name__ == "__main__": asyncio.run(main())