fixos 2.2.52__tar.gz → 2.2.53__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fixos-2.2.52 → fixos-2.2.53}/PKG-INFO +1 -1
- fixos-2.2.53/VERSION +1 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/_cleanup_space.py +36 -4
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/cleanup_cmd.py +23 -5
- {fixos-2.2.52 → fixos-2.2.53}/fixos/config.py +24 -2
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/native_scan.py +15 -3
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/quick_snapshot.py +31 -2
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/service_cleanup.py +51 -6
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/service_scanner.py +61 -2
- {fixos-2.2.52 → fixos-2.2.53}/fixos/interactive/cleanup_planner.py +26 -28
- {fixos-2.2.52 → fixos-2.2.53}/fixos.egg-info/PKG-INFO +1 -1
- {fixos-2.2.52 → fixos-2.2.53}/fixos.egg-info/SOURCES.txt +2 -0
- {fixos-2.2.52 → fixos-2.2.53}/pyproject.toml +1 -1
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_cleanup_effectiveness.py +93 -0
- fixos-2.2.53/tests/unit/test_cleanup_planner.py +41 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_cleanup_tmp.py +43 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_native_scan.py +26 -0
- fixos-2.2.53/tests/unit/test_ollama_endpoint.py +46 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_quick_snapshot.py +33 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_service_cleanup.py +75 -0
- fixos-2.2.52/VERSION +0 -1
- {fixos-2.2.52 → fixos-2.2.53}/.env.example +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/CHANGELOG.md +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/LICENSE +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/MANIFEST.in +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/README.md +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docker/README.md +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docker/TEST_RESULTS.md +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docker/TEST_RESULTS_V2.md +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docker/alpine/Dockerfile +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docker/arch/Dockerfile +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docker/base/Dockerfile +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docker/broken-audio/Dockerfile +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docker/broken-full/Dockerfile +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docker/broken-network/Dockerfile +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docker/broken-thumbnails/Dockerfile +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docker/debian/Dockerfile +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docker/docker-compose.multi-system.yml +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docker/docker-compose.yml +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docker/fedora/Dockerfile +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docker/test-multi-system.sh +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docker/test-scenarios.sh +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docker/ubuntu/Dockerfile +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docker/validate-scenario.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docs/examples/advanced_usage.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/docs/examples/quickstart.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/__init__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/__main__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/agent/__init__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/agent/autonomous.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/agent/autonomous_session.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/agent/hitl.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/agent/hitl_session.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/agent/session_core.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/agent/session_handlers.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/agent/session_io.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/anonymizer.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/__init__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/__main__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/_cleanup_flatpak.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/_cleanup_home.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/_cleanup_snap.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/_cleanup_system.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/_cleanup_utils.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/_cleanup_venvs.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/ask_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/config_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/dashboard.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/features_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/fix_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/history_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/jetbrains_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/main.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/orchestrate_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/output_formatter.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/profile_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/projects_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/provider_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/quick_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/quickfix_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/report_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/rollback_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/scan_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/shared.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/shell_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/token_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli/watch_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/cli.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/config_interactive.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/constants.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/__init__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/_flatpak_analysis_mixin.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/_flatpak_execution_mixin.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/_flatpak_recommendations_mixin.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/_storage_container_mixin.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/_storage_system_mixin.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/_storage_user_mixin.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/cache_discovery.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/checks/__init__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/checks/_shared.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/checks/audio.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/checks/file_analysis.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/checks/hardware.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/checks/packages.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/checks/resources.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/checks/security.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/checks/storage_optimization.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/checks/system_core.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/checks/thumbnails.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/dev_project_analyzer.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/disk_analyzer.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/docker_network_cleanup.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/docker_startup_optimizer.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/flatpak_analyzer.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/jetbrains_ai.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/jetbrains_recovery.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/orphaned_workloads.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/process_chains.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/project_scanner.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/service_details.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/storage_analyzer.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/system_checks.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/utils.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/diagnostics/venv_cleanup.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/endpoint_refresh.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/features/__init__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/features/auditor.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/features/catalog.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/features/data/packages.yaml +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/features/data/profiles/developer.yaml +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/features/data/profiles/office.yaml +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/features/data/profiles/sysadmin.yaml +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/features/installer.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/features/profiles.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/features/renderer.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/fixes/__init__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/interactive/__init__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/llm_shell.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/orchestrator/__init__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/orchestrator/executor.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/orchestrator/graph.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/orchestrator/orchestrator.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/orchestrator/rollback.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/orphan_pins.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/platform_utils.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/plugins/__init__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/plugins/base.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/plugins/builtin/__init__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/plugins/builtin/audio.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/plugins/builtin/disk.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/plugins/builtin/hardware.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/plugins/builtin/resources.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/plugins/builtin/security.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/plugins/builtin/thumbnails.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/plugins/registry.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/profiles/__init__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/profiles/desktop.yaml +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/profiles/developer.yaml +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/profiles/minimal.yaml +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/profiles/server.yaml +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/providers/__init__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/providers/gemini_native.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/providers/llm.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/providers/llm_analyzer.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/providers/schemas.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/system_checks.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/utils/__init__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/utils/anonymizer.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/utils/terminal.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/utils/timeout.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/utils/web_search.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos/watch.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos.egg-info/dependency_links.txt +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos.egg-info/entry_points.txt +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos.egg-info/requires.txt +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/fixos.egg-info/top_level.txt +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/pytest.ini +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/requirements-dev.txt +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/requirements.txt +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/scripts/pyqual-calibrate.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/setup.cfg +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/setup.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/__init__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/conftest.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/e2e/__init__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/e2e/test_anonymization_layers.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/e2e/test_audio_broken.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/e2e/test_cli.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/e2e/test_diagnostics_integration.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/e2e/test_executor.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/e2e/test_multi_system.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/e2e/test_network_broken.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/e2e/test_thumbnails_broken.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/test_fixos.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/__init__.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_anonymizer.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_ask_and_nl_group.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_cache_discovery.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_cleanup_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_cleanup_retention.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_cleanup_safety.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_cleanup_space.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_cleanup_utils.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_cli_main.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_core.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_dashboard.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_dev_project_analyzer.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_disk_analyzer.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_docker_network_cleanup.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_docker_startup_optimizer.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_endpoint_refresh.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_executor.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_gemini_native_client.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_jetbrains_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_jetbrains_recovery.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_llm_auth_stop.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_llm_provider.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_orchestrator.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_orphan_pins.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_orphaned_workloads.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_packaging_data.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_process_chains.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_project_scanner.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_projects_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_quick_cmd.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_resources_quick.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_security_quick.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_service_scanner.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_shell_ux.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_storage_analyzer.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_system_checks_parallel.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_venv_cleanup.py +0 -0
- {fixos-2.2.52 → fixos-2.2.53}/tests/unit/test_version_parity.py +0 -0
fixos-2.2.53/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2.2.53
|
|
@@ -52,6 +52,31 @@ def _run_cmd(argv: list[str], timeout: int = 60) -> subprocess.CompletedProcess:
|
|
|
52
52
|
return subprocess.run(argv, capture_output=True, text=True, timeout=timeout, check=False)
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
_DU_BATCH = 512
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _dir_sizes(paths: list[Path]) -> dict[str, int]:
|
|
59
|
+
"""Sizes in bytes for many directories with one ``du`` per batch.
|
|
60
|
+
|
|
61
|
+
``--count-links`` keeps each directory independent of the others, as a
|
|
62
|
+
separate ``du`` would be; NUL-terminated output survives any file name.
|
|
63
|
+
Paths missing from the result must be measured individually.
|
|
64
|
+
"""
|
|
65
|
+
sizes: dict[str, int] = {}
|
|
66
|
+
for start in range(0, len(paths), _DU_BATCH):
|
|
67
|
+
batch = [str(path) for path in paths[start : start + _DU_BATCH]]
|
|
68
|
+
try:
|
|
69
|
+
result = _run_cmd(["du", "-sbl0", "--", *batch], timeout=300)
|
|
70
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
71
|
+
continue
|
|
72
|
+
# du exits 1 when some subtree is unreadable but still prints totals.
|
|
73
|
+
for record in result.stdout.split("\0"):
|
|
74
|
+
size, sep, name = record.partition("\t")
|
|
75
|
+
if sep and size.isdigit():
|
|
76
|
+
sizes[name] = int(size)
|
|
77
|
+
return sizes
|
|
78
|
+
|
|
79
|
+
|
|
55
80
|
def _dir_size(path: Path) -> int:
|
|
56
81
|
"""Return directory size in bytes (du -sb with a Python fallback)."""
|
|
57
82
|
try:
|
|
@@ -876,6 +901,7 @@ def _scan_tmp(
|
|
|
876
901
|
cutoff_seconds = days * 86400
|
|
877
902
|
tmp_path = Path(tmp_dir) if tmp_dir is not None else Path("/tmp")
|
|
878
903
|
candidates = []
|
|
904
|
+
directories = []
|
|
879
905
|
|
|
880
906
|
if tmp_path.is_dir():
|
|
881
907
|
try:
|
|
@@ -893,23 +919,29 @@ def _scan_tmp(
|
|
|
893
919
|
mtime = stat_val.st_mtime
|
|
894
920
|
age_seconds = now - mtime
|
|
895
921
|
if age_seconds >= cutoff_seconds:
|
|
896
|
-
|
|
897
|
-
stat_val.st_size if not item.is_dir() else _dir_size(item)
|
|
898
|
-
)
|
|
922
|
+
is_dir = stat_module.S_ISDIR(stat_val.st_mode)
|
|
899
923
|
age_days = age_seconds / 86400
|
|
900
924
|
candidates.append({
|
|
901
925
|
"description": f"{name} (wiek: {age_days:.1f} dni)",
|
|
902
926
|
"path": item,
|
|
903
927
|
"name": name,
|
|
904
|
-
"size_bytes":
|
|
928
|
+
"size_bytes": 0 if is_dir else stat_val.st_size,
|
|
905
929
|
"note": f"{age_days:.1f} dni",
|
|
906
930
|
"age_days": age_days,
|
|
907
931
|
})
|
|
932
|
+
if is_dir:
|
|
933
|
+
directories.append(candidates[-1])
|
|
908
934
|
except (OSError, PermissionError):
|
|
909
935
|
continue
|
|
910
936
|
except (OSError, PermissionError):
|
|
911
937
|
pass
|
|
912
938
|
|
|
939
|
+
# One `du` per directory meant thousands of processes on a busy /tmp.
|
|
940
|
+
sizes = _dir_sizes([c["path"] for c in directories])
|
|
941
|
+
for candidate in directories:
|
|
942
|
+
size = sizes.get(str(candidate["path"]))
|
|
943
|
+
candidate["size_bytes"] = size if size is not None else _dir_size(candidate["path"])
|
|
944
|
+
|
|
913
945
|
return candidates
|
|
914
946
|
|
|
915
947
|
|
|
@@ -129,11 +129,7 @@ def _clean_terminal_input(value: str) -> str:
|
|
|
129
129
|
|
|
130
130
|
def _display_service_item(svc: dict) -> None:
|
|
131
131
|
"""Display a single service item with details."""
|
|
132
|
-
size_str = (
|
|
133
|
-
f"{svc['size_gb']:.2f} GB"
|
|
134
|
-
if svc["size_gb"] >= 1
|
|
135
|
-
else f"{svc['size_mb']:.0f} MB"
|
|
136
|
-
)
|
|
132
|
+
size_str = _size_str(svc)
|
|
137
133
|
risk = svc.get("risk_level") or ("safe" if svc["safe_to_cleanup"] else "review")
|
|
138
134
|
safe_text, safe_color = _RISK_LABELS.get(risk, _RISK_LABELS["review"])
|
|
139
135
|
|
|
@@ -164,6 +160,8 @@ def _display_service_item(svc: dict) -> None:
|
|
|
164
160
|
)
|
|
165
161
|
else:
|
|
166
162
|
click.echo(f" Komponenty: {svc['details']['components']}")
|
|
163
|
+
elif svc["service_type"] == "docker" and svc["details"].get("fast_inventory"):
|
|
164
|
+
_display_docker_inventory(svc["details"]["fast_inventory"])
|
|
167
165
|
elif svc["service_type"] == "ollama" and svc["details"].get("models"):
|
|
168
166
|
models = svc["details"]["models"]
|
|
169
167
|
if models:
|
|
@@ -173,6 +171,26 @@ def _display_service_item(svc: dict) -> None:
|
|
|
173
171
|
click.echo()
|
|
174
172
|
|
|
175
173
|
|
|
174
|
+
def _display_docker_inventory(inventory: dict) -> None:
|
|
175
|
+
"""Show what a timed-out `docker system df` could not size."""
|
|
176
|
+
click.echo(" Docker — szybki spis (bez rozmiarów wolumenów):")
|
|
177
|
+
pairs = (
|
|
178
|
+
("obrazy", "images", "dangling_images", "wiszących"),
|
|
179
|
+
("kontenery", "containers", "stopped_containers", "zatrzymanych"),
|
|
180
|
+
("wolumeny", "volumes", "dangling_volumes", "nieużywanych — dane, tylko ręcznie"),
|
|
181
|
+
)
|
|
182
|
+
for label, total_key, part_key, part_label in pairs:
|
|
183
|
+
if total_key in inventory:
|
|
184
|
+
part = inventory.get(part_key)
|
|
185
|
+
suffix = f" ({part} {part_label})" if part is not None else ""
|
|
186
|
+
click.echo(f" • {label}: {inventory[total_key]}{suffix}")
|
|
187
|
+
if "build_cache_total_gb" in inventory:
|
|
188
|
+
click.echo(
|
|
189
|
+
f" • cache buildów: {inventory['build_cache_total_gb']:.2f} GB "
|
|
190
|
+
f"(odzyskiwalne {inventory.get('build_cache_reclaimable_gb', 0):.2f} GB)"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
176
194
|
def _error_message(result: dict) -> str:
|
|
177
195
|
"""Human-readable failure reason, even when the command's own stderr was
|
|
178
196
|
empty (e.g. suppressed with 2>/dev/null inside the cleanup command
|
|
@@ -128,7 +128,8 @@ PROVIDER_DEFAULTS = {
|
|
|
128
128
|
"description": "Cerebras – najszybsze wnioskowanie na świecie, darmowy tier",
|
|
129
129
|
},
|
|
130
130
|
"ollama": {
|
|
131
|
-
|
|
131
|
+
# Resolved from OLLAMA_HOST at load time, see ollama_host_url().
|
|
132
|
+
"base_url": None,
|
|
132
133
|
"model": "llama3.2",
|
|
133
134
|
"key_env": None,
|
|
134
135
|
"key_url": "https://ollama.com/download",
|
|
@@ -142,6 +143,22 @@ _OPENROUTER_MODEL_MIGRATIONS = {
|
|
|
142
143
|
}
|
|
143
144
|
|
|
144
145
|
|
|
146
|
+
DEFAULT_OLLAMA_HOST = "127.0.0.1:11434"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def ollama_host_url() -> str:
|
|
150
|
+
"""Ollama server URL from ``OLLAMA_HOST`` (the variable Ollama itself reads).
|
|
151
|
+
|
|
152
|
+
Single source for every fixOS component talking to Ollama: the LLM
|
|
153
|
+
provider and model cleanup previously disagreed when OLLAMA_HOST pointed
|
|
154
|
+
to another host.
|
|
155
|
+
"""
|
|
156
|
+
host = os.environ.get("OLLAMA_HOST", "").strip() or DEFAULT_OLLAMA_HOST
|
|
157
|
+
if "://" not in host:
|
|
158
|
+
host = f"http://{host}"
|
|
159
|
+
return host.rstrip("/")
|
|
160
|
+
|
|
161
|
+
|
|
145
162
|
def _normalize_model_id(provider: str, model: str) -> str:
|
|
146
163
|
"""Normalize provider-specific model IDs without changing valid choices."""
|
|
147
164
|
normalized = model.strip()
|
|
@@ -293,7 +310,12 @@ class FixOsConfig:
|
|
|
293
310
|
|
|
294
311
|
# Base URL
|
|
295
312
|
url_env_key = f"{cfg.provider.upper()}_BASE_URL"
|
|
296
|
-
cfg.base_url =
|
|
313
|
+
cfg.base_url = (
|
|
314
|
+
base_url
|
|
315
|
+
or os.environ.get(url_env_key)
|
|
316
|
+
or pdef["base_url"]
|
|
317
|
+
or (f"{ollama_host_url()}/v1" if cfg.provider == "ollama" else None)
|
|
318
|
+
)
|
|
297
319
|
|
|
298
320
|
# Transport Gemini: natywne API Google albo endpoint OpenAI-compatible
|
|
299
321
|
cfg.gemini_transport = os.environ.get("GEMINI_TRANSPORT", "native").lower()
|
|
@@ -69,9 +69,19 @@ def measure_tree(
|
|
|
69
69
|
|
|
70
70
|
|
|
71
71
|
def measure_batch(
|
|
72
|
-
paths: list[Path],
|
|
72
|
+
paths: list[Path],
|
|
73
|
+
exclude: tuple[str, ...] = (),
|
|
74
|
+
*,
|
|
75
|
+
native: bool = True,
|
|
76
|
+
timeout: float = 120,
|
|
77
|
+
python_fallback: bool = True,
|
|
73
78
|
) -> dict[str, dict]:
|
|
74
|
-
"""Measure multiple paths, accelerating with Rust batch measurement if available.
|
|
79
|
+
"""Measure multiple paths, accelerating with Rust batch measurement if available.
|
|
80
|
+
|
|
81
|
+
Results are keyed by the resolved path. ``python_fallback=False`` returns
|
|
82
|
+
``{}`` instead of an unbounded Python walk when the native batch is
|
|
83
|
+
missing, fails or exceeds ``timeout``; time-budgeted callers use it.
|
|
84
|
+
"""
|
|
75
85
|
if not paths:
|
|
76
86
|
return {}
|
|
77
87
|
if native:
|
|
@@ -84,7 +94,7 @@ def measure_batch(
|
|
|
84
94
|
[str(executable), "batch-measure", *abs_paths],
|
|
85
95
|
capture_output=True,
|
|
86
96
|
text=True,
|
|
87
|
-
timeout=
|
|
97
|
+
timeout=timeout,
|
|
88
98
|
check=True,
|
|
89
99
|
)
|
|
90
100
|
data = json.loads(result.stdout)
|
|
@@ -103,6 +113,8 @@ def measure_batch(
|
|
|
103
113
|
return res
|
|
104
114
|
except (OSError, ValueError, subprocess.SubprocessError):
|
|
105
115
|
pass
|
|
116
|
+
if not python_fallback:
|
|
117
|
+
return {}
|
|
106
118
|
return {str(p.resolve()): measure_python(p, exclude) for p in paths}
|
|
107
119
|
|
|
108
120
|
|
|
@@ -348,11 +348,15 @@ def _measure_caches(
|
|
|
348
348
|
from fixos.diagnostics.native_scan import measure_batch
|
|
349
349
|
|
|
350
350
|
unique_paths = list({path for _, path in jobs})
|
|
351
|
-
|
|
351
|
+
# The native batch had a 120 s limit and an unbounded Python
|
|
352
|
+
# fallback; on a busy disk that turned "quick" into minutes.
|
|
353
|
+
batch_result = measure_batch(
|
|
354
|
+
unique_paths, timeout=timeout * 2, python_fallback=False
|
|
355
|
+
)
|
|
352
356
|
if isinstance(batch_result, dict) and batch_result:
|
|
353
357
|
unresolved_jobs: list[tuple[CacheRule, Path]] = []
|
|
354
358
|
for rule, path in jobs:
|
|
355
|
-
m = batch_result.get(str(path))
|
|
359
|
+
m = batch_result.get(str(path.resolve()))
|
|
356
360
|
if isinstance(m, dict) and "bytes" in m:
|
|
357
361
|
kib = (m["bytes"] + 1023) // 1024
|
|
358
362
|
measured.setdefault(rule.id, []).append((path, kib))
|
|
@@ -569,6 +573,28 @@ def _history_row(
|
|
|
569
573
|
}
|
|
570
574
|
|
|
571
575
|
|
|
576
|
+
def _fill_from_history(
|
|
577
|
+
items: list[dict[str, Any]], history: list[dict[str, Any]]
|
|
578
|
+
) -> None:
|
|
579
|
+
"""Show the last complete size for caches that missed the time budget.
|
|
580
|
+
|
|
581
|
+
The item stays ``complete: False`` so growth reports never compare it.
|
|
582
|
+
"""
|
|
583
|
+
for item in items:
|
|
584
|
+
if item.get("complete", True):
|
|
585
|
+
continue
|
|
586
|
+
for row in reversed(history):
|
|
587
|
+
if row.get("cache_complete", {}).get(item["id"]) and item["id"] in row.get(
|
|
588
|
+
"cache_sizes", {}
|
|
589
|
+
):
|
|
590
|
+
size = int(row["cache_sizes"][item["id"]])
|
|
591
|
+
if size > item["size_bytes"]:
|
|
592
|
+
item["size_bytes"] = size
|
|
593
|
+
item["size_gb"] = round(size / 1024**3, 3)
|
|
594
|
+
item["last_complete_at"] = row.get("generated_at")
|
|
595
|
+
break
|
|
596
|
+
|
|
597
|
+
|
|
572
598
|
def _parse_timestamp(row: dict[str, Any]) -> datetime | None:
|
|
573
599
|
try:
|
|
574
600
|
parsed = datetime.fromisoformat(str(row["generated_at"]))
|
|
@@ -836,6 +862,9 @@ def collect_quick_snapshot(
|
|
|
836
862
|
history_path = _state_dir(state_dir) / "quick-history.json"
|
|
837
863
|
history = _load_history(history_path)
|
|
838
864
|
current_row = _history_row(generated_at, resources, safe_items, review_items)
|
|
865
|
+
_fill_from_history(safe_items + review_items, history)
|
|
866
|
+
safe_items.sort(key=lambda item: item["size_bytes"], reverse=True)
|
|
867
|
+
review_items.sort(key=lambda item: item["size_bytes"], reverse=True)
|
|
839
868
|
growth = _growth_report(history, current_row, now, max(1, hours))
|
|
840
869
|
alerts = _alerts(resources, growth, thresholds)
|
|
841
870
|
|
|
@@ -8,6 +8,7 @@ import os
|
|
|
8
8
|
import re
|
|
9
9
|
import shlex
|
|
10
10
|
import shutil
|
|
11
|
+
import stat
|
|
11
12
|
import subprocess
|
|
12
13
|
import urllib.error
|
|
13
14
|
import urllib.request
|
|
@@ -341,10 +342,9 @@ class ServiceCleaner:
|
|
|
341
342
|
|
|
342
343
|
@staticmethod
|
|
343
344
|
def _ollama_base_url() -> str:
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
return host.rstrip("/")
|
|
345
|
+
from ..config import ollama_host_url
|
|
346
|
+
|
|
347
|
+
return ollama_host_url()
|
|
348
348
|
|
|
349
349
|
@staticmethod
|
|
350
350
|
def _parse_ollama_modified_at(value: str) -> datetime:
|
|
@@ -1480,6 +1480,16 @@ class ServiceCleaner:
|
|
|
1480
1480
|
result["estimated_max_gb"] = estimated_gb
|
|
1481
1481
|
return result
|
|
1482
1482
|
|
|
1483
|
+
planned_paths = service.details.get("paths") or [service.path]
|
|
1484
|
+
# uv/pnpm/conda caches hardlink files into environments; deleting
|
|
1485
|
+
# the cache link frees nothing while another link survives.
|
|
1486
|
+
measure_shared = service_enum != ServiceType.DOCKER
|
|
1487
|
+
shared_before_gb = (
|
|
1488
|
+
self._shared_hardlink_bytes(planned_paths) / 1024**3
|
|
1489
|
+
if measure_shared
|
|
1490
|
+
else 0.0
|
|
1491
|
+
)
|
|
1492
|
+
|
|
1483
1493
|
# Execute cleanup
|
|
1484
1494
|
cleanup_result = subprocess.run(
|
|
1485
1495
|
service.cleanup_command,
|
|
@@ -1492,7 +1502,6 @@ class ServiceCleaner:
|
|
|
1492
1502
|
|
|
1493
1503
|
# Check new size
|
|
1494
1504
|
measure = getattr(self.scanner, "measure_service_size_mb", None)
|
|
1495
|
-
planned_paths = service.details.get("paths") or [service.path]
|
|
1496
1505
|
if callable(measure):
|
|
1497
1506
|
new_size_mb = sum(
|
|
1498
1507
|
measure(service_enum, path, refresh=True) for path in planned_paths
|
|
@@ -1502,7 +1511,14 @@ class ServiceCleaner:
|
|
|
1502
1511
|
self.scanner._get_path_size_mb(path) for path in planned_paths
|
|
1503
1512
|
)
|
|
1504
1513
|
new_size_gb = new_size_mb / 1024
|
|
1505
|
-
|
|
1514
|
+
shared_after_gb = (
|
|
1515
|
+
self._shared_hardlink_bytes(planned_paths) / 1024**3
|
|
1516
|
+
if measure_shared
|
|
1517
|
+
else 0.0
|
|
1518
|
+
)
|
|
1519
|
+
freed_gb = max(
|
|
1520
|
+
0, (initial_size - shared_before_gb) - (new_size_gb - shared_after_gb)
|
|
1521
|
+
)
|
|
1506
1522
|
|
|
1507
1523
|
result["success"] = cleanup_result.returncode == 0
|
|
1508
1524
|
result["output"] = cleanup_result.stdout
|
|
@@ -1511,6 +1527,9 @@ class ServiceCleaner:
|
|
|
1511
1527
|
result["space_freed_gb"] = round(freed_gb, 3)
|
|
1512
1528
|
result["initial_size_gb"] = initial_size
|
|
1513
1529
|
result["remaining_size_gb"] = round(new_size_gb, 3)
|
|
1530
|
+
result["shared_hardlink_gb"] = round(
|
|
1531
|
+
max(0.0, shared_before_gb - shared_after_gb), 3
|
|
1532
|
+
)
|
|
1514
1533
|
|
|
1515
1534
|
except Exception as e: # noqa: BLE001 - stored in result["error"] for the caller to display
|
|
1516
1535
|
result["error"] = str(e)
|
|
@@ -1528,6 +1547,32 @@ class ServiceCleaner:
|
|
|
1528
1547
|
== "docker builder prune --force --filter until=168h"
|
|
1529
1548
|
)
|
|
1530
1549
|
|
|
1550
|
+
@staticmethod
|
|
1551
|
+
def _shared_hardlink_bytes(paths: list[str]) -> int:
|
|
1552
|
+
"""Allocated bytes under ``paths`` whose inode also has links elsewhere.
|
|
1553
|
+
|
|
1554
|
+
Removing such a link does not return blocks to the filesystem, so it
|
|
1555
|
+
must not count as freed space. Links inside the measured trees are
|
|
1556
|
+
counted together; only an inode with more links than seen is shared.
|
|
1557
|
+
"""
|
|
1558
|
+
inodes: dict[tuple[int, int], list[int]] = {}
|
|
1559
|
+
for root in paths:
|
|
1560
|
+
if not root or os.path.islink(root) or not os.path.isdir(root):
|
|
1561
|
+
continue
|
|
1562
|
+
for dirpath, _dirnames, filenames in os.walk(root):
|
|
1563
|
+
for name in filenames:
|
|
1564
|
+
try:
|
|
1565
|
+
st = os.lstat(os.path.join(dirpath, name))
|
|
1566
|
+
except OSError:
|
|
1567
|
+
continue
|
|
1568
|
+
if st.st_nlink < 2 or not stat.S_ISREG(st.st_mode):
|
|
1569
|
+
continue
|
|
1570
|
+
entry = inodes.setdefault(
|
|
1571
|
+
(st.st_dev, st.st_ino), [st.st_nlink, 0, st.st_blocks * 512]
|
|
1572
|
+
)
|
|
1573
|
+
entry[1] += 1
|
|
1574
|
+
return sum(size for nlink, seen, size in inodes.values() if seen < nlink)
|
|
1575
|
+
|
|
1531
1576
|
@staticmethod
|
|
1532
1577
|
def _estimated_cleanup_gb(service) -> float:
|
|
1533
1578
|
"""Best available upper bound for a dry-run cleanup.
|
|
@@ -330,7 +330,12 @@ class ServiceDataScanner:
|
|
|
330
330
|
for path in matching_paths:
|
|
331
331
|
if os.path.exists(path):
|
|
332
332
|
info = self._analyze_service_path(service_type, path)
|
|
333
|
-
|
|
333
|
+
# An unmeasurable entry (slow Docker daemon) has no size to
|
|
334
|
+
# compare; dropping it hid hundreds of GB from the report.
|
|
335
|
+
if info and (
|
|
336
|
+
info.size_mb >= self.threshold_mb
|
|
337
|
+
or info.details.get("size_unknown")
|
|
338
|
+
):
|
|
334
339
|
results.append(info)
|
|
335
340
|
if len(results) > 1:
|
|
336
341
|
return self._merge_by_risk(results)
|
|
@@ -543,10 +548,64 @@ class ServiceDataScanner:
|
|
|
543
548
|
preview_command=ServiceCleaner.get_preview_command(ServiceType.DOCKER, path),
|
|
544
549
|
safe_to_cleanup=False,
|
|
545
550
|
impact="high",
|
|
546
|
-
details={
|
|
551
|
+
details={
|
|
552
|
+
"size_unknown": True,
|
|
553
|
+
"reason": "docker_system_df_timeout",
|
|
554
|
+
"fast_inventory": self._docker_fast_inventory(),
|
|
555
|
+
},
|
|
547
556
|
risk_level=ServiceCleaner.get_risk_level(ServiceType.DOCKER, path),
|
|
548
557
|
)
|
|
549
558
|
|
|
559
|
+
@staticmethod
|
|
560
|
+
def _docker_fast_inventory(timeout_s: float = 10.0) -> dict[str, Any]:
|
|
561
|
+
"""Counts and build-cache size from Docker commands that stay fast.
|
|
562
|
+
|
|
563
|
+
``docker system df`` walks every volume to size it; with hundreds of
|
|
564
|
+
volumes it can take minutes even on an idle host. Listing objects and
|
|
565
|
+
``docker builder du`` answer in well under a second, so a timed-out
|
|
566
|
+
scan can still show where Docker holds data. Volume sizes stay
|
|
567
|
+
unknown; orphaned volumes are data and are only counted.
|
|
568
|
+
"""
|
|
569
|
+
|
|
570
|
+
def lines(*args: str) -> list[str] | None:
|
|
571
|
+
try:
|
|
572
|
+
result = subprocess.run(
|
|
573
|
+
["docker", *args],
|
|
574
|
+
capture_output=True,
|
|
575
|
+
text=True,
|
|
576
|
+
timeout=timeout_s,
|
|
577
|
+
check=False,
|
|
578
|
+
)
|
|
579
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
580
|
+
return None
|
|
581
|
+
if result.returncode != 0:
|
|
582
|
+
return None
|
|
583
|
+
return [line for line in result.stdout.splitlines() if line.strip()]
|
|
584
|
+
|
|
585
|
+
inventory: dict[str, Any] = {}
|
|
586
|
+
queries = {
|
|
587
|
+
"images": ("image", "ls", "-q", "--no-trunc"),
|
|
588
|
+
"dangling_images": ("image", "ls", "-q", "--no-trunc", "-f", "dangling=true"),
|
|
589
|
+
"containers": ("ps", "-aq"),
|
|
590
|
+
"stopped_containers": ("ps", "-aq", "-f", "status=exited"),
|
|
591
|
+
"volumes": ("volume", "ls", "-q"),
|
|
592
|
+
"dangling_volumes": ("volume", "ls", "-q", "-f", "dangling=true"),
|
|
593
|
+
}
|
|
594
|
+
for key, args in queries.items():
|
|
595
|
+
found = lines(*args)
|
|
596
|
+
if found is not None:
|
|
597
|
+
inventory[key] = len(set(found))
|
|
598
|
+
|
|
599
|
+
builder = lines("builder", "du")
|
|
600
|
+
if builder is not None:
|
|
601
|
+
for line in builder:
|
|
602
|
+
label, _, value = line.partition(":")
|
|
603
|
+
label = label.strip().lower()
|
|
604
|
+
if label in ("total", "reclaimable") and value.strip():
|
|
605
|
+
size_mb = ServiceDataScanner._parse_human_size_to_mb(value.strip())
|
|
606
|
+
inventory[f"build_cache_{label}_gb"] = round(size_mb / 1024, 3)
|
|
607
|
+
return inventory
|
|
608
|
+
|
|
550
609
|
def _get_docker_daemon_size_mb(self, *, refresh: bool = False) -> float | None:
|
|
551
610
|
"""Total Docker disk usage as reported by the daemon itself."""
|
|
552
611
|
usage = self._get_docker_daemon_usage(refresh=refresh)
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
1
|
"""
|
|
3
2
|
Interactive Cleanup Planner for fixOS
|
|
4
3
|
Groups cleanup actions and provides interactive selection
|
|
5
4
|
"""
|
|
6
5
|
|
|
7
6
|
import json
|
|
8
|
-
from typing import Dict, List, Any
|
|
9
7
|
from dataclasses import dataclass
|
|
10
8
|
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class Priority(Enum):
|
|
@@ -42,7 +42,7 @@ class CleanupAction:
|
|
|
42
42
|
impact: str
|
|
43
43
|
category: str = ""
|
|
44
44
|
estimated_time: str = ""
|
|
45
|
-
dependencies:
|
|
45
|
+
dependencies: list[str] = None
|
|
46
46
|
preview_command: str = ""
|
|
47
47
|
|
|
48
48
|
def __post_init__(self):
|
|
@@ -116,8 +116,8 @@ class CleanupPlanner:
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
def group_by_category(
|
|
119
|
-
self, suggestions:
|
|
120
|
-
) ->
|
|
119
|
+
self, suggestions: list[dict]
|
|
120
|
+
) -> dict[str, list[CleanupAction]]:
|
|
121
121
|
"""Group cleanup suggestions by category"""
|
|
122
122
|
grouped = {}
|
|
123
123
|
|
|
@@ -133,13 +133,12 @@ class CleanupPlanner:
|
|
|
133
133
|
grouped[category] = []
|
|
134
134
|
grouped[category].append(action)
|
|
135
135
|
|
|
136
|
-
except Exception:
|
|
137
|
-
# Skip invalid suggestions
|
|
136
|
+
except Exception: # noqa: BLE001, S112 - one malformed suggestion must not drop the whole plan
|
|
138
137
|
continue
|
|
139
138
|
|
|
140
139
|
# Sort actions within each category by priority and size
|
|
141
|
-
for
|
|
142
|
-
|
|
140
|
+
for actions in grouped.values():
|
|
141
|
+
actions.sort(
|
|
143
142
|
key=lambda x: (self._priority_score(x.priority), x.size_gb),
|
|
144
143
|
reverse=True,
|
|
145
144
|
)
|
|
@@ -147,25 +146,25 @@ class CleanupPlanner:
|
|
|
147
146
|
return grouped
|
|
148
147
|
|
|
149
148
|
def prioritize_actions(
|
|
150
|
-
self, grouped_actions:
|
|
151
|
-
) ->
|
|
149
|
+
self, grouped_actions: dict[str, list[CleanupAction]]
|
|
150
|
+
) -> list[CleanupAction]:
|
|
152
151
|
"""Create prioritized list of all actions"""
|
|
153
152
|
all_actions = []
|
|
154
153
|
|
|
155
154
|
# Add high priority actions first
|
|
156
|
-
for
|
|
155
|
+
for actions in grouped_actions.values():
|
|
157
156
|
high_priority = [
|
|
158
157
|
a for a in actions if a.priority in [Priority.CRITICAL, Priority.HIGH]
|
|
159
158
|
]
|
|
160
159
|
all_actions.extend(high_priority)
|
|
161
160
|
|
|
162
161
|
# Add medium priority actions
|
|
163
|
-
for
|
|
162
|
+
for actions in grouped_actions.values():
|
|
164
163
|
medium_priority = [a for a in actions if a.priority == Priority.MEDIUM]
|
|
165
164
|
all_actions.extend(medium_priority)
|
|
166
165
|
|
|
167
166
|
# Add low priority actions
|
|
168
|
-
for
|
|
167
|
+
for actions in grouped_actions.values():
|
|
169
168
|
low_priority = [a for a in actions if a.priority == Priority.LOW]
|
|
170
169
|
all_actions.extend(low_priority)
|
|
171
170
|
|
|
@@ -181,7 +180,7 @@ class CleanupPlanner:
|
|
|
181
180
|
|
|
182
181
|
return all_actions
|
|
183
182
|
|
|
184
|
-
def create_cleanup_plan(self, suggestions:
|
|
183
|
+
def create_cleanup_plan(self, suggestions: list[dict]) -> dict[str, Any]:
|
|
185
184
|
"""Create comprehensive cleanup plan"""
|
|
186
185
|
grouped = self.group_by_category(suggestions)
|
|
187
186
|
prioritized = self.prioritize_actions(grouped)
|
|
@@ -237,7 +236,7 @@ class CleanupPlanner:
|
|
|
237
236
|
|
|
238
237
|
return plan
|
|
239
238
|
|
|
240
|
-
def interactive_selection(self, plan:
|
|
239
|
+
def interactive_selection(self, plan: dict[str, Any]) -> dict[str, Any]:
|
|
241
240
|
"""Interactive selection process (simulated for now)"""
|
|
242
241
|
selected_actions = []
|
|
243
242
|
|
|
@@ -255,7 +254,7 @@ class CleanupPlanner:
|
|
|
255
254
|
"selection_method": "auto_safe_high_impact",
|
|
256
255
|
}
|
|
257
256
|
|
|
258
|
-
def _dict_to_action(self, suggestion:
|
|
257
|
+
def _dict_to_action(self, suggestion: dict) -> CleanupAction:
|
|
259
258
|
"""Convert dictionary suggestion to CleanupAction"""
|
|
260
259
|
try:
|
|
261
260
|
# Need to handle strings if suggestion payload comes in as string not Enum
|
|
@@ -285,8 +284,7 @@ class CleanupPlanner:
|
|
|
285
284
|
dependencies=suggestion.get("dependencies", []),
|
|
286
285
|
preview_command=suggestion.get("preview_command", ""),
|
|
287
286
|
)
|
|
288
|
-
except Exception:
|
|
289
|
-
# Create default action for invalid suggestions
|
|
287
|
+
except Exception: # noqa: BLE001 - invalid suggestions become a low-priority default action
|
|
290
288
|
return CleanupAction(
|
|
291
289
|
type=CleanupType.LARGE_FILE,
|
|
292
290
|
priority=Priority.LOW,
|
|
@@ -298,7 +296,7 @@ class CleanupPlanner:
|
|
|
298
296
|
impact="none",
|
|
299
297
|
)
|
|
300
298
|
|
|
301
|
-
def _action_to_dict(self, action: CleanupAction) ->
|
|
299
|
+
def _action_to_dict(self, action: CleanupAction) -> dict[str, Any]:
|
|
302
300
|
"""Convert CleanupAction to dictionary"""
|
|
303
301
|
return {
|
|
304
302
|
"type": action.type.value,
|
|
@@ -345,7 +343,7 @@ class CleanupPlanner:
|
|
|
345
343
|
return scores.get(priority, 1)
|
|
346
344
|
|
|
347
345
|
@staticmethod
|
|
348
|
-
def _rec_safe_high_impact(prioritized:
|
|
346
|
+
def _rec_safe_high_impact(prioritized: list[CleanupAction]) -> list[dict]:
|
|
349
347
|
actions = [
|
|
350
348
|
a
|
|
351
349
|
for a in prioritized
|
|
@@ -368,7 +366,7 @@ class CleanupPlanner:
|
|
|
368
366
|
]
|
|
369
367
|
|
|
370
368
|
@staticmethod
|
|
371
|
-
def _rec_cache(grouped:
|
|
369
|
+
def _rec_cache(grouped: dict[str, list[CleanupAction]]) -> list[dict]:
|
|
372
370
|
actions = grouped.get("cache", [])
|
|
373
371
|
if not actions:
|
|
374
372
|
return []
|
|
@@ -387,7 +385,7 @@ class CleanupPlanner:
|
|
|
387
385
|
]
|
|
388
386
|
|
|
389
387
|
@staticmethod
|
|
390
|
-
def _rec_logs(grouped:
|
|
388
|
+
def _rec_logs(grouped: dict[str, list[CleanupAction]]) -> list[dict]:
|
|
391
389
|
actions = grouped.get("logs", [])
|
|
392
390
|
if not actions:
|
|
393
391
|
return []
|
|
@@ -406,7 +404,7 @@ class CleanupPlanner:
|
|
|
406
404
|
]
|
|
407
405
|
|
|
408
406
|
@staticmethod
|
|
409
|
-
def _rec_manual(prioritized:
|
|
407
|
+
def _rec_manual(prioritized: list[CleanupAction]) -> list[dict]:
|
|
410
408
|
actions = [a for a in prioritized if not a.safe and a.size_gb > 1.0]
|
|
411
409
|
if not actions:
|
|
412
410
|
return []
|
|
@@ -423,10 +421,10 @@ class CleanupPlanner:
|
|
|
423
421
|
]
|
|
424
422
|
|
|
425
423
|
def _generate_recommendations(
|
|
426
|
-
self, grouped:
|
|
427
|
-
) ->
|
|
424
|
+
self, grouped: dict[str, list[CleanupAction]], prioritized: list[CleanupAction]
|
|
425
|
+
) -> list[dict]:
|
|
428
426
|
"""Generate cleanup recommendations"""
|
|
429
|
-
recs:
|
|
427
|
+
recs: list[dict] = []
|
|
430
428
|
recs.extend(self._rec_safe_high_impact(prioritized))
|
|
431
429
|
recs.extend(self._rec_cache(grouped))
|
|
432
430
|
recs.extend(self._rec_logs(grouped))
|
|
@@ -441,7 +439,7 @@ def main():
|
|
|
441
439
|
{
|
|
442
440
|
"type": "cache_cleanup",
|
|
443
441
|
"priority": "high",
|
|
444
|
-
"path":
|
|
442
|
+
"path": str(Path.home() / ".cache" / "npm"),
|
|
445
443
|
"size_gb": 2.5,
|
|
446
444
|
"description": "Clear npm cache",
|
|
447
445
|
"command": "npm cache clean --force",
|
|
@@ -188,6 +188,7 @@ tests/unit/test_ask_and_nl_group.py
|
|
|
188
188
|
tests/unit/test_cache_discovery.py
|
|
189
189
|
tests/unit/test_cleanup_cmd.py
|
|
190
190
|
tests/unit/test_cleanup_effectiveness.py
|
|
191
|
+
tests/unit/test_cleanup_planner.py
|
|
191
192
|
tests/unit/test_cleanup_retention.py
|
|
192
193
|
tests/unit/test_cleanup_safety.py
|
|
193
194
|
tests/unit/test_cleanup_space.py
|
|
@@ -208,6 +209,7 @@ tests/unit/test_jetbrains_recovery.py
|
|
|
208
209
|
tests/unit/test_llm_auth_stop.py
|
|
209
210
|
tests/unit/test_llm_provider.py
|
|
210
211
|
tests/unit/test_native_scan.py
|
|
212
|
+
tests/unit/test_ollama_endpoint.py
|
|
211
213
|
tests/unit/test_orchestrator.py
|
|
212
214
|
tests/unit/test_orphan_pins.py
|
|
213
215
|
tests/unit/test_orphaned_workloads.py
|