fixos 2.2.52__tar.gz → 2.2.54__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.
Files changed (235) hide show
  1. {fixos-2.2.52 → fixos-2.2.54}/PKG-INFO +1 -1
  2. fixos-2.2.54/VERSION +1 -0
  3. fixos-2.2.54/fixos/cli/_cleanup_docker_volumes.py +221 -0
  4. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/_cleanup_space.py +36 -4
  5. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/cleanup_cmd.py +61 -7
  6. {fixos-2.2.52 → fixos-2.2.54}/fixos/config.py +24 -2
  7. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/_storage_container_mixin.py +8 -4
  8. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/native_scan.py +15 -3
  9. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/quick_snapshot.py +31 -2
  10. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/service_cleanup.py +51 -6
  11. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/service_scanner.py +61 -2
  12. {fixos-2.2.52 → fixos-2.2.54}/fixos/interactive/cleanup_planner.py +26 -28
  13. {fixos-2.2.52 → fixos-2.2.54}/fixos.egg-info/PKG-INFO +1 -1
  14. {fixos-2.2.52 → fixos-2.2.54}/fixos.egg-info/SOURCES.txt +4 -0
  15. {fixos-2.2.52 → fixos-2.2.54}/pyproject.toml +1 -1
  16. fixos-2.2.54/tests/unit/test_cleanup_docker_volumes.py +161 -0
  17. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_cleanup_effectiveness.py +93 -0
  18. fixos-2.2.54/tests/unit/test_cleanup_planner.py +41 -0
  19. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_cleanup_tmp.py +43 -0
  20. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_native_scan.py +26 -0
  21. fixos-2.2.54/tests/unit/test_ollama_endpoint.py +46 -0
  22. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_quick_snapshot.py +33 -0
  23. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_service_cleanup.py +75 -0
  24. fixos-2.2.52/VERSION +0 -1
  25. {fixos-2.2.52 → fixos-2.2.54}/.env.example +0 -0
  26. {fixos-2.2.52 → fixos-2.2.54}/CHANGELOG.md +0 -0
  27. {fixos-2.2.52 → fixos-2.2.54}/LICENSE +0 -0
  28. {fixos-2.2.52 → fixos-2.2.54}/MANIFEST.in +0 -0
  29. {fixos-2.2.52 → fixos-2.2.54}/README.md +0 -0
  30. {fixos-2.2.52 → fixos-2.2.54}/docker/README.md +0 -0
  31. {fixos-2.2.52 → fixos-2.2.54}/docker/TEST_RESULTS.md +0 -0
  32. {fixos-2.2.52 → fixos-2.2.54}/docker/TEST_RESULTS_V2.md +0 -0
  33. {fixos-2.2.52 → fixos-2.2.54}/docker/alpine/Dockerfile +0 -0
  34. {fixos-2.2.52 → fixos-2.2.54}/docker/arch/Dockerfile +0 -0
  35. {fixos-2.2.52 → fixos-2.2.54}/docker/base/Dockerfile +0 -0
  36. {fixos-2.2.52 → fixos-2.2.54}/docker/broken-audio/Dockerfile +0 -0
  37. {fixos-2.2.52 → fixos-2.2.54}/docker/broken-full/Dockerfile +0 -0
  38. {fixos-2.2.52 → fixos-2.2.54}/docker/broken-network/Dockerfile +0 -0
  39. {fixos-2.2.52 → fixos-2.2.54}/docker/broken-thumbnails/Dockerfile +0 -0
  40. {fixos-2.2.52 → fixos-2.2.54}/docker/debian/Dockerfile +0 -0
  41. {fixos-2.2.52 → fixos-2.2.54}/docker/docker-compose.multi-system.yml +0 -0
  42. {fixos-2.2.52 → fixos-2.2.54}/docker/docker-compose.yml +0 -0
  43. {fixos-2.2.52 → fixos-2.2.54}/docker/fedora/Dockerfile +0 -0
  44. {fixos-2.2.52 → fixos-2.2.54}/docker/test-multi-system.sh +0 -0
  45. {fixos-2.2.52 → fixos-2.2.54}/docker/test-scenarios.sh +0 -0
  46. {fixos-2.2.52 → fixos-2.2.54}/docker/ubuntu/Dockerfile +0 -0
  47. {fixos-2.2.52 → fixos-2.2.54}/docker/validate-scenario.py +0 -0
  48. {fixos-2.2.52 → fixos-2.2.54}/docs/examples/advanced_usage.py +0 -0
  49. {fixos-2.2.52 → fixos-2.2.54}/docs/examples/quickstart.py +0 -0
  50. {fixos-2.2.52 → fixos-2.2.54}/fixos/__init__.py +0 -0
  51. {fixos-2.2.52 → fixos-2.2.54}/fixos/__main__.py +0 -0
  52. {fixos-2.2.52 → fixos-2.2.54}/fixos/agent/__init__.py +0 -0
  53. {fixos-2.2.52 → fixos-2.2.54}/fixos/agent/autonomous.py +0 -0
  54. {fixos-2.2.52 → fixos-2.2.54}/fixos/agent/autonomous_session.py +0 -0
  55. {fixos-2.2.52 → fixos-2.2.54}/fixos/agent/hitl.py +0 -0
  56. {fixos-2.2.52 → fixos-2.2.54}/fixos/agent/hitl_session.py +0 -0
  57. {fixos-2.2.52 → fixos-2.2.54}/fixos/agent/session_core.py +0 -0
  58. {fixos-2.2.52 → fixos-2.2.54}/fixos/agent/session_handlers.py +0 -0
  59. {fixos-2.2.52 → fixos-2.2.54}/fixos/agent/session_io.py +0 -0
  60. {fixos-2.2.52 → fixos-2.2.54}/fixos/anonymizer.py +0 -0
  61. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/__init__.py +0 -0
  62. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/__main__.py +0 -0
  63. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/_cleanup_flatpak.py +0 -0
  64. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/_cleanup_home.py +0 -0
  65. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/_cleanup_snap.py +0 -0
  66. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/_cleanup_system.py +0 -0
  67. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/_cleanup_utils.py +0 -0
  68. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/_cleanup_venvs.py +0 -0
  69. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/ask_cmd.py +0 -0
  70. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/config_cmd.py +0 -0
  71. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/dashboard.py +0 -0
  72. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/features_cmd.py +0 -0
  73. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/fix_cmd.py +0 -0
  74. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/history_cmd.py +0 -0
  75. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/jetbrains_cmd.py +0 -0
  76. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/main.py +0 -0
  77. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/orchestrate_cmd.py +0 -0
  78. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/output_formatter.py +0 -0
  79. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/profile_cmd.py +0 -0
  80. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/projects_cmd.py +0 -0
  81. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/provider_cmd.py +0 -0
  82. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/quick_cmd.py +0 -0
  83. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/quickfix_cmd.py +0 -0
  84. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/report_cmd.py +0 -0
  85. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/rollback_cmd.py +0 -0
  86. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/scan_cmd.py +0 -0
  87. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/shared.py +0 -0
  88. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/shell_cmd.py +0 -0
  89. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/token_cmd.py +0 -0
  90. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli/watch_cmd.py +0 -0
  91. {fixos-2.2.52 → fixos-2.2.54}/fixos/cli.py +0 -0
  92. {fixos-2.2.52 → fixos-2.2.54}/fixos/config_interactive.py +0 -0
  93. {fixos-2.2.52 → fixos-2.2.54}/fixos/constants.py +0 -0
  94. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/__init__.py +0 -0
  95. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/_flatpak_analysis_mixin.py +0 -0
  96. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/_flatpak_execution_mixin.py +0 -0
  97. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/_flatpak_recommendations_mixin.py +0 -0
  98. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/_storage_system_mixin.py +0 -0
  99. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/_storage_user_mixin.py +0 -0
  100. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/cache_discovery.py +0 -0
  101. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/checks/__init__.py +0 -0
  102. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/checks/_shared.py +0 -0
  103. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/checks/audio.py +0 -0
  104. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/checks/file_analysis.py +0 -0
  105. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/checks/hardware.py +0 -0
  106. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/checks/packages.py +0 -0
  107. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/checks/resources.py +0 -0
  108. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/checks/security.py +0 -0
  109. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/checks/storage_optimization.py +0 -0
  110. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/checks/system_core.py +0 -0
  111. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/checks/thumbnails.py +0 -0
  112. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/dev_project_analyzer.py +0 -0
  113. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/disk_analyzer.py +0 -0
  114. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/docker_network_cleanup.py +0 -0
  115. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/docker_startup_optimizer.py +0 -0
  116. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/flatpak_analyzer.py +0 -0
  117. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/jetbrains_ai.py +0 -0
  118. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/jetbrains_recovery.py +0 -0
  119. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/orphaned_workloads.py +0 -0
  120. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/process_chains.py +0 -0
  121. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/project_scanner.py +0 -0
  122. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/service_details.py +0 -0
  123. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/storage_analyzer.py +0 -0
  124. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/system_checks.py +0 -0
  125. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/utils.py +0 -0
  126. {fixos-2.2.52 → fixos-2.2.54}/fixos/diagnostics/venv_cleanup.py +0 -0
  127. {fixos-2.2.52 → fixos-2.2.54}/fixos/endpoint_refresh.py +0 -0
  128. {fixos-2.2.52 → fixos-2.2.54}/fixos/features/__init__.py +0 -0
  129. {fixos-2.2.52 → fixos-2.2.54}/fixos/features/auditor.py +0 -0
  130. {fixos-2.2.52 → fixos-2.2.54}/fixos/features/catalog.py +0 -0
  131. {fixos-2.2.52 → fixos-2.2.54}/fixos/features/data/packages.yaml +0 -0
  132. {fixos-2.2.52 → fixos-2.2.54}/fixos/features/data/profiles/developer.yaml +0 -0
  133. {fixos-2.2.52 → fixos-2.2.54}/fixos/features/data/profiles/office.yaml +0 -0
  134. {fixos-2.2.52 → fixos-2.2.54}/fixos/features/data/profiles/sysadmin.yaml +0 -0
  135. {fixos-2.2.52 → fixos-2.2.54}/fixos/features/installer.py +0 -0
  136. {fixos-2.2.52 → fixos-2.2.54}/fixos/features/profiles.py +0 -0
  137. {fixos-2.2.52 → fixos-2.2.54}/fixos/features/renderer.py +0 -0
  138. {fixos-2.2.52 → fixos-2.2.54}/fixos/fixes/__init__.py +0 -0
  139. {fixos-2.2.52 → fixos-2.2.54}/fixos/interactive/__init__.py +0 -0
  140. {fixos-2.2.52 → fixos-2.2.54}/fixos/llm_shell.py +0 -0
  141. {fixos-2.2.52 → fixos-2.2.54}/fixos/orchestrator/__init__.py +0 -0
  142. {fixos-2.2.52 → fixos-2.2.54}/fixos/orchestrator/executor.py +0 -0
  143. {fixos-2.2.52 → fixos-2.2.54}/fixos/orchestrator/graph.py +0 -0
  144. {fixos-2.2.52 → fixos-2.2.54}/fixos/orchestrator/orchestrator.py +0 -0
  145. {fixos-2.2.52 → fixos-2.2.54}/fixos/orchestrator/rollback.py +0 -0
  146. {fixos-2.2.52 → fixos-2.2.54}/fixos/orphan_pins.py +0 -0
  147. {fixos-2.2.52 → fixos-2.2.54}/fixos/platform_utils.py +0 -0
  148. {fixos-2.2.52 → fixos-2.2.54}/fixos/plugins/__init__.py +0 -0
  149. {fixos-2.2.52 → fixos-2.2.54}/fixos/plugins/base.py +0 -0
  150. {fixos-2.2.52 → fixos-2.2.54}/fixos/plugins/builtin/__init__.py +0 -0
  151. {fixos-2.2.52 → fixos-2.2.54}/fixos/plugins/builtin/audio.py +0 -0
  152. {fixos-2.2.52 → fixos-2.2.54}/fixos/plugins/builtin/disk.py +0 -0
  153. {fixos-2.2.52 → fixos-2.2.54}/fixos/plugins/builtin/hardware.py +0 -0
  154. {fixos-2.2.52 → fixos-2.2.54}/fixos/plugins/builtin/resources.py +0 -0
  155. {fixos-2.2.52 → fixos-2.2.54}/fixos/plugins/builtin/security.py +0 -0
  156. {fixos-2.2.52 → fixos-2.2.54}/fixos/plugins/builtin/thumbnails.py +0 -0
  157. {fixos-2.2.52 → fixos-2.2.54}/fixos/plugins/registry.py +0 -0
  158. {fixos-2.2.52 → fixos-2.2.54}/fixos/profiles/__init__.py +0 -0
  159. {fixos-2.2.52 → fixos-2.2.54}/fixos/profiles/desktop.yaml +0 -0
  160. {fixos-2.2.52 → fixos-2.2.54}/fixos/profiles/developer.yaml +0 -0
  161. {fixos-2.2.52 → fixos-2.2.54}/fixos/profiles/minimal.yaml +0 -0
  162. {fixos-2.2.52 → fixos-2.2.54}/fixos/profiles/server.yaml +0 -0
  163. {fixos-2.2.52 → fixos-2.2.54}/fixos/providers/__init__.py +0 -0
  164. {fixos-2.2.52 → fixos-2.2.54}/fixos/providers/gemini_native.py +0 -0
  165. {fixos-2.2.52 → fixos-2.2.54}/fixos/providers/llm.py +0 -0
  166. {fixos-2.2.52 → fixos-2.2.54}/fixos/providers/llm_analyzer.py +0 -0
  167. {fixos-2.2.52 → fixos-2.2.54}/fixos/providers/schemas.py +0 -0
  168. {fixos-2.2.52 → fixos-2.2.54}/fixos/system_checks.py +0 -0
  169. {fixos-2.2.52 → fixos-2.2.54}/fixos/utils/__init__.py +0 -0
  170. {fixos-2.2.52 → fixos-2.2.54}/fixos/utils/anonymizer.py +0 -0
  171. {fixos-2.2.52 → fixos-2.2.54}/fixos/utils/terminal.py +0 -0
  172. {fixos-2.2.52 → fixos-2.2.54}/fixos/utils/timeout.py +0 -0
  173. {fixos-2.2.52 → fixos-2.2.54}/fixos/utils/web_search.py +0 -0
  174. {fixos-2.2.52 → fixos-2.2.54}/fixos/watch.py +0 -0
  175. {fixos-2.2.52 → fixos-2.2.54}/fixos.egg-info/dependency_links.txt +0 -0
  176. {fixos-2.2.52 → fixos-2.2.54}/fixos.egg-info/entry_points.txt +0 -0
  177. {fixos-2.2.52 → fixos-2.2.54}/fixos.egg-info/requires.txt +0 -0
  178. {fixos-2.2.52 → fixos-2.2.54}/fixos.egg-info/top_level.txt +0 -0
  179. {fixos-2.2.52 → fixos-2.2.54}/pytest.ini +0 -0
  180. {fixos-2.2.52 → fixos-2.2.54}/requirements-dev.txt +0 -0
  181. {fixos-2.2.52 → fixos-2.2.54}/requirements.txt +0 -0
  182. {fixos-2.2.52 → fixos-2.2.54}/scripts/pyqual-calibrate.py +0 -0
  183. {fixos-2.2.52 → fixos-2.2.54}/setup.cfg +0 -0
  184. {fixos-2.2.52 → fixos-2.2.54}/setup.py +0 -0
  185. {fixos-2.2.52 → fixos-2.2.54}/tests/__init__.py +0 -0
  186. {fixos-2.2.52 → fixos-2.2.54}/tests/conftest.py +0 -0
  187. {fixos-2.2.52 → fixos-2.2.54}/tests/e2e/__init__.py +0 -0
  188. {fixos-2.2.52 → fixos-2.2.54}/tests/e2e/test_anonymization_layers.py +0 -0
  189. {fixos-2.2.52 → fixos-2.2.54}/tests/e2e/test_audio_broken.py +0 -0
  190. {fixos-2.2.52 → fixos-2.2.54}/tests/e2e/test_cli.py +0 -0
  191. {fixos-2.2.52 → fixos-2.2.54}/tests/e2e/test_diagnostics_integration.py +0 -0
  192. {fixos-2.2.52 → fixos-2.2.54}/tests/e2e/test_executor.py +0 -0
  193. {fixos-2.2.52 → fixos-2.2.54}/tests/e2e/test_multi_system.py +0 -0
  194. {fixos-2.2.52 → fixos-2.2.54}/tests/e2e/test_network_broken.py +0 -0
  195. {fixos-2.2.52 → fixos-2.2.54}/tests/e2e/test_thumbnails_broken.py +0 -0
  196. {fixos-2.2.52 → fixos-2.2.54}/tests/test_fixos.py +0 -0
  197. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/__init__.py +0 -0
  198. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_anonymizer.py +0 -0
  199. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_ask_and_nl_group.py +0 -0
  200. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_cache_discovery.py +0 -0
  201. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_cleanup_cmd.py +0 -0
  202. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_cleanup_retention.py +0 -0
  203. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_cleanup_safety.py +0 -0
  204. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_cleanup_space.py +0 -0
  205. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_cleanup_utils.py +0 -0
  206. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_cli_main.py +0 -0
  207. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_core.py +0 -0
  208. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_dashboard.py +0 -0
  209. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_dev_project_analyzer.py +0 -0
  210. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_disk_analyzer.py +0 -0
  211. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_docker_network_cleanup.py +0 -0
  212. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_docker_startup_optimizer.py +0 -0
  213. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_endpoint_refresh.py +0 -0
  214. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_executor.py +0 -0
  215. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_gemini_native_client.py +0 -0
  216. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_jetbrains_cmd.py +0 -0
  217. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_jetbrains_recovery.py +0 -0
  218. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_llm_auth_stop.py +0 -0
  219. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_llm_provider.py +0 -0
  220. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_orchestrator.py +0 -0
  221. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_orphan_pins.py +0 -0
  222. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_orphaned_workloads.py +0 -0
  223. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_packaging_data.py +0 -0
  224. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_process_chains.py +0 -0
  225. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_project_scanner.py +0 -0
  226. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_projects_cmd.py +0 -0
  227. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_quick_cmd.py +0 -0
  228. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_resources_quick.py +0 -0
  229. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_security_quick.py +0 -0
  230. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_service_scanner.py +0 -0
  231. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_shell_ux.py +0 -0
  232. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_storage_analyzer.py +0 -0
  233. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_system_checks_parallel.py +0 -0
  234. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_venv_cleanup.py +0 -0
  235. {fixos-2.2.52 → fixos-2.2.54}/tests/unit/test_version_parity.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixos
3
- Version: 2.2.52
3
+ Version: 2.2.54
4
4
  Summary: AI-powered Linux/Windows diagnostics and repair – audio, hardware, system issues
5
5
  Home-page: https://github.com/wronai/fixos
6
6
  Author: fixos contributors
fixos-2.2.54/VERSION ADDED
@@ -0,0 +1 @@
1
+ 2.2.54
@@ -0,0 +1,221 @@
1
+ """Selective removal of unused (dangling) Docker volumes.
2
+
3
+ Volumes hold real data, so nothing here is bulk: candidates are listed with
4
+ name, Compose project and age, the user picks them, and each one is checked
5
+ again right before ``docker volume rm``. ``--yes`` never selects volumes whose
6
+ name looks like stored data (databases, git mirrors, backups, ...).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import subprocess
15
+ from concurrent.futures import ThreadPoolExecutor
16
+ from datetime import datetime, timezone
17
+
18
+ import click
19
+
20
+ from fixos.cli import _cleanup_space as space
21
+ from fixos.cli._cleanup_utils import _format_bytes
22
+
23
+ # Name tokens that usually mean irreplaceable data rather than a cache.
24
+ _DATA_TOKENS = re.compile(
25
+ r"(?:^|[_.-])(mirror|git|gitea|onedev|repo|repos|postgres|postgresql|pg|"
26
+ r"mysql|mariadb|mongo|mongodb|redis|db|database|backup|backups|minio|s3|"
27
+ r"vault|secrets?|keycloak|grafana|prometheus|influx|elastic|opensearch|"
28
+ r"nextcloud|uploads|media|webui|openwebui|open-webui|n8n|storage|certs|"
29
+ r"letsencrypt|wordpress|mattermost|synapse|chat-history)(?:$|[_.-])",
30
+ re.IGNORECASE,
31
+ )
32
+ _INSPECT_BATCH = 100
33
+
34
+
35
+ def _docker_lines(args: list[str], timeout: int = 30) -> list[str] | None:
36
+ try:
37
+ result = space._run_cmd(["docker", *args], timeout=timeout)
38
+ except (OSError, subprocess.TimeoutExpired):
39
+ return None
40
+ if result.returncode != 0:
41
+ return None
42
+ return [line for line in result.stdout.splitlines() if line.strip()]
43
+
44
+
45
+ def _is_unused(name: str) -> bool:
46
+ """Re-check right before removal: still dangling and no container (even stopped)."""
47
+ dangling = _docker_lines(["volume", "ls", "-q", "-f", "dangling=true", "-f", f"name=^{name}$"])
48
+ containers = _docker_lines(["ps", "-aq", "--filter", f"volume={name}"])
49
+ return dangling is not None and name in dangling and containers == []
50
+
51
+
52
+ def _age_days(created_at: str, now: datetime) -> float | None:
53
+ try:
54
+ created = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
55
+ except ValueError:
56
+ return None
57
+ if created.tzinfo is None:
58
+ created = created.replace(tzinfo=timezone.utc)
59
+ return (now - created).total_seconds() / 86400
60
+
61
+
62
+ _SIZE_WORKERS = 8
63
+
64
+
65
+ def _du_batch(command: list[str], paths: list[str]) -> dict[str, int]:
66
+ sizes: dict[str, int] = {}
67
+ try:
68
+ result = space._run_cmd([*command, "-sbl0", "--", *paths], timeout=300)
69
+ except (OSError, subprocess.TimeoutExpired):
70
+ return sizes
71
+ for record in result.stdout.split("\0"):
72
+ size, sep, path = record.partition("\t")
73
+ if sep and size.isdigit():
74
+ sizes[path] = int(size)
75
+ return sizes
76
+
77
+
78
+ def _volume_sizes(mountpoints: list[str]) -> dict[str, int]:
79
+ """Sizes via ``du`` in parallel batches; root-owned volumes use ``sudo -n``.
80
+
81
+ ``sudo -n`` never prompts: without passwordless sudo the size stays unknown.
82
+ Unreadable mountpoints go straight to ``sudo -n``, so a partial size of an
83
+ unreadable directory is never reported.
84
+ """
85
+ readable = [m for m in mountpoints if os.access(m, os.R_OK | os.X_OK)]
86
+ privileged = [m for m in mountpoints if m not in set(readable)]
87
+ jobs = [(["du"], readable[i::_SIZE_WORKERS]) for i in range(_SIZE_WORKERS)]
88
+ jobs += [(["sudo", "-n", "du"], privileged[i::_SIZE_WORKERS]) for i in range(_SIZE_WORKERS)]
89
+ jobs = [(command, paths) for command, paths in jobs if paths]
90
+ sizes: dict[str, int] = {}
91
+ if not jobs:
92
+ return sizes
93
+ with ThreadPoolExecutor(max_workers=min(_SIZE_WORKERS, len(jobs))) as pool:
94
+ for result in pool.map(lambda job: _du_batch(*job), jobs):
95
+ sizes.update(result)
96
+ return sizes
97
+
98
+
99
+ def _unused_volumes(
100
+ days: float | None, match: list[str], now: datetime | None = None
101
+ ) -> list[dict] | None:
102
+ names = _docker_lines(["volume", "ls", "-q", "-f", "dangling=true"])
103
+ if names is None:
104
+ return None
105
+ now = now or datetime.now(timezone.utc)
106
+ records: list[dict] = []
107
+ for start in range(0, len(names), _INSPECT_BATCH):
108
+ raw = _docker_lines(["volume", "inspect", *names[start : start + _INSPECT_BATCH]], timeout=60)
109
+ if raw is None:
110
+ return None
111
+ records.extend(json.loads("\n".join(raw)))
112
+ candidates = []
113
+ for record in records:
114
+ name = record.get("Name", "")
115
+ if match and not any(token.lower() in name.lower() for token in match):
116
+ continue
117
+ age = _age_days(record.get("CreatedAt", ""), now)
118
+ if days is not None and (age is None or age < days):
119
+ continue
120
+ labels = record.get("Labels") or {}
121
+ project = labels.get("com.docker.compose.project") or "(bez projektu)"
122
+ protected = bool(_DATA_TOKENS.search(name))
123
+ candidates.append(
124
+ {
125
+ "name": name,
126
+ "description": name,
127
+ "project": project,
128
+ "age_days": age,
129
+ "mountpoint": record.get("Mountpoint", ""),
130
+ "protected": protected,
131
+ "size_bytes": 0,
132
+ "size_known": False,
133
+ }
134
+ )
135
+ sizes = _volume_sizes([c["mountpoint"] for c in candidates if c["mountpoint"]])
136
+ for candidate in candidates:
137
+ if candidate["mountpoint"] in sizes:
138
+ candidate["size_bytes"] = sizes[candidate["mountpoint"]]
139
+ candidate["size_known"] = True
140
+ age = candidate["age_days"]
141
+ size = _format_bytes(candidate["size_bytes"]) if candidate["size_known"] else "rozmiar nieznany"
142
+ candidate["note"] = (
143
+ f"{candidate['project']}, {age:.0f} dni" if age is not None else candidate["project"]
144
+ ) + (" — wygląda na DANE, tylko ręczny wybór" if candidate["protected"] else "")
145
+ candidate["size_label"] = size
146
+ candidates.sort(key=lambda c: (c["size_known"], c["size_bytes"]), reverse=True)
147
+ return candidates
148
+
149
+
150
+ def _cleanup_docker_volumes(
151
+ json_output: bool,
152
+ dry_run: bool,
153
+ list_only: bool,
154
+ yes: bool,
155
+ days: float | None = None,
156
+ match: list[str] | None = None,
157
+ ) -> None:
158
+ """List unused Docker volumes and remove the selected ones."""
159
+ candidates = _unused_volumes(days, match or [])
160
+ if json_output:
161
+ space._echo_json(
162
+ {
163
+ "error": None if candidates is not None else "docker volume ls failed",
164
+ "candidates": [
165
+ {k: c[k] for k in ("name", "project", "age_days", "size_bytes", "size_known", "protected")}
166
+ for c in candidates or []
167
+ ],
168
+ }
169
+ )
170
+ return
171
+ if candidates is None:
172
+ click.echo(click.style("Błąd: nie udało się odczytać wolumenów Dockera.", fg="red"))
173
+ return
174
+ if not candidates:
175
+ click.echo(click.style("Brak nieużywanych wolumenów Dockera dla podanych filtrów.", fg="green"))
176
+ return
177
+ known = sum(c["size_bytes"] for c in candidates if c["size_known"])
178
+ click.echo(
179
+ f"\n{click.style('DOCKER — nieużywane wolumeny (dane!):', fg='magenta', bold=True)} "
180
+ f"{len(candidates)}, znany rozmiar {_format_bytes(known)}"
181
+ )
182
+ for index, item in enumerate(candidates, 1):
183
+ color = "red" if item["protected"] else "yellow"
184
+ click.echo(
185
+ f" [{index:3d}] {click.style(item['name'], fg=color)}: {item['size_label']} — {item['note']}"
186
+ )
187
+ if list_only:
188
+ return
189
+ if yes:
190
+ selected = [i for i, c in enumerate(candidates) if not c["protected"]]
191
+ skipped = len(candidates) - len(selected)
192
+ if skipped:
193
+ click.echo(f" --yes pomija {skipped} wolumenów wyglądających na dane; wybierz je ręcznie.")
194
+ else:
195
+ selected = space._choose(candidates, "Numery wolumenów do usunięcia (np. 1,2, all)")
196
+ if not selected:
197
+ click.echo("Pominięto.")
198
+ return
199
+ if dry_run:
200
+ space._show_dry_run_commands([["docker", "volume", "rm", candidates[i]["name"]] for i in selected])
201
+ return
202
+ if not yes and not space._confirm_remove():
203
+ click.echo("Anulowano.")
204
+ return
205
+ freed = 0
206
+ for index in selected:
207
+ name = candidates[index]["name"]
208
+ if not _is_unused(name):
209
+ click.echo(f" ⏭️ {name}: jest już używany albo nie istnieje — pominięto")
210
+ continue
211
+ try:
212
+ result = space._run_cmd(["docker", "volume", "rm", name], timeout=120)
213
+ except (OSError, subprocess.TimeoutExpired) as exc:
214
+ click.echo(click.style(f" ❌ {name}: {exc}", fg="red"))
215
+ continue
216
+ if result.returncode == 0:
217
+ freed += candidates[index]["size_bytes"]
218
+ click.echo(click.style(f" ✅ {name}", fg="green"))
219
+ else:
220
+ click.echo(click.style(f" ❌ {name}: {result.stderr.strip()[:120]}", fg="red"))
221
+ click.echo(f"Usunięto wolumeny o znanym rozmiarze łącznie {_format_bytes(freed)}.")
@@ -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
- size = (
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": size,
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
 
@@ -14,6 +14,8 @@ import re
14
14
 
15
15
  import click
16
16
 
17
+ from fixos.cli._cleanup_docker_volumes import _cleanup_docker_volumes
18
+
17
19
  # Re-export public symbols used by fixos.cli (backward-compat)
18
20
  from fixos.cli._cleanup_flatpak import _cleanup_flatpak_detailed
19
21
  from fixos.cli._cleanup_space import (
@@ -129,11 +131,7 @@ def _clean_terminal_input(value: str) -> str:
129
131
 
130
132
  def _display_service_item(svc: dict) -> None:
131
133
  """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
- )
134
+ size_str = _size_str(svc)
137
135
  risk = svc.get("risk_level") or ("safe" if svc["safe_to_cleanup"] else "review")
138
136
  safe_text, safe_color = _RISK_LABELS.get(risk, _RISK_LABELS["review"])
139
137
 
@@ -164,6 +162,8 @@ def _display_service_item(svc: dict) -> None:
164
162
  )
165
163
  else:
166
164
  click.echo(f" Komponenty: {svc['details']['components']}")
165
+ elif svc["service_type"] == "docker" and svc["details"].get("fast_inventory"):
166
+ _display_docker_inventory(svc["details"]["fast_inventory"])
167
167
  elif svc["service_type"] == "ollama" and svc["details"].get("models"):
168
168
  models = svc["details"]["models"]
169
169
  if models:
@@ -173,6 +173,26 @@ def _display_service_item(svc: dict) -> None:
173
173
  click.echo()
174
174
 
175
175
 
176
+ def _display_docker_inventory(inventory: dict) -> None:
177
+ """Show what a timed-out `docker system df` could not size."""
178
+ click.echo(" Docker — szybki spis (bez rozmiarów wolumenów):")
179
+ pairs = (
180
+ ("obrazy", "images", "dangling_images", "wiszących"),
181
+ ("kontenery", "containers", "stopped_containers", "zatrzymanych"),
182
+ ("wolumeny", "volumes", "dangling_volumes", "nieużywanych — dane, tylko ręcznie"),
183
+ )
184
+ for label, total_key, part_key, part_label in pairs:
185
+ if total_key in inventory:
186
+ part = inventory.get(part_key)
187
+ suffix = f" ({part} {part_label})" if part is not None else ""
188
+ click.echo(f" • {label}: {inventory[total_key]}{suffix}")
189
+ if "build_cache_total_gb" in inventory:
190
+ click.echo(
191
+ f" • cache buildów: {inventory['build_cache_total_gb']:.2f} GB "
192
+ f"(odzyskiwalne {inventory.get('build_cache_reclaimable_gb', 0):.2f} GB)"
193
+ )
194
+
195
+
176
196
  def _error_message(result: dict) -> str:
177
197
  """Human-readable failure reason, even when the command's own stderr was
178
198
  empty (e.g. suppressed with 2>/dev/null inside the cleanup command
@@ -1593,7 +1613,7 @@ def _execute_yes_cleanup(plan: dict, scanner, dry_run: bool = False) -> None:
1593
1613
  "Wyczyść konkretną usługę "
1594
1614
  "(docker, docker-all, docker-old, docker-containers, docker-networks, "
1595
1615
  "docker-stale-services, orphaned-projects, ollama-old, snap-old, "
1596
- "journal, user-cache, jetbrains, libvirt, gitive, docker-buildcache, tmp, venvs-old, "
1616
+ "journal, user-cache, jetbrains, libvirt, gitive, docker-buildcache, docker-volumes, tmp, venvs-old, "
1597
1617
  "npm, ...)"
1598
1618
  ),
1599
1619
  )
@@ -1701,6 +1721,23 @@ def _execute_yes_cleanup(plan: dict, scanner, dry_run: bool = False) -> None:
1701
1721
  f"(domyślnie {DEFAULT_OLLAMA_OLD_UNUSED_DAYS}); pomija modele aktualnie uruchomione"
1702
1722
  ),
1703
1723
  )
1724
+ @click.option(
1725
+ "--docker-volumes",
1726
+ "docker_volumes",
1727
+ is_flag=True,
1728
+ default=False,
1729
+ help=(
1730
+ "Wybierz i usuń nieużywane wolumeny Dockera (nazwa, projekt Compose, wiek, "
1731
+ "rozmiar przez du/sudo -n); filtry --days i --match; --yes pomija "
1732
+ "wolumeny wyglądające na dane"
1733
+ ),
1734
+ )
1735
+ @click.option(
1736
+ "--match",
1737
+ "match",
1738
+ default=None,
1739
+ help="Dla --docker-volumes: fragmenty nazw rozdzielone przecinkami, np. ollama,huggingface",
1740
+ )
1704
1741
  @click.option(
1705
1742
  "--snap-old",
1706
1743
  "snap_old",
@@ -1875,6 +1912,8 @@ def cleanup_services(
1875
1912
  list_orphan_pins,
1876
1913
  process_hours,
1877
1914
  ollama_old,
1915
+ docker_volumes,
1916
+ match,
1878
1917
  snap_old,
1879
1918
  journal_cleanup,
1880
1919
  user_cache,
@@ -1933,6 +1972,8 @@ def cleanup_services(
1933
1972
  fixos cleanup --unpin-orphan-project /absolute/missing/project
1934
1973
  # usuwa ochronę, ale nie zmienia Dockera ani procesów
1935
1974
  fixos cleanup --ollama-old --days 90 --dry-run
1975
+ fixos cleanup --docker-volumes --list # nieużywane wolumeny: nazwa, projekt, wiek, rozmiar
1976
+ fixos cleanup --docker-volumes --match ollama,huggingface --days 90
1936
1977
  # modele Ollama niezmieniane od 90+ dni
1937
1978
  fixos cleanup -c ollama-old
1938
1979
  fixos cleanup --snap-old # wyłączone rewizje snapów
@@ -1986,6 +2027,7 @@ def cleanup_services(
1986
2027
  list_orphan_pins,
1987
2028
  ollama_old,
1988
2029
  snap_old,
2030
+ docker_volumes,
1989
2031
  journal_cleanup,
1990
2032
  user_cache,
1991
2033
  jetbrains,
@@ -2035,6 +2077,7 @@ def cleanup_services(
2035
2077
  ollama_old,
2036
2078
  docker_buildcache,
2037
2079
  snap_old,
2080
+ docker_volumes,
2038
2081
  journal_cleanup,
2039
2082
  user_cache,
2040
2083
  jetbrains,
@@ -2088,6 +2131,17 @@ def cleanup_services(
2088
2131
  "libvirt": (libvirt, _cleanup_libvirt),
2089
2132
  "gitive": (gitive, _cleanup_gitive),
2090
2133
  "docker-buildcache": (docker_buildcache, _cleanup_docker_buildcache),
2134
+ "docker-volumes": (
2135
+ docker_volumes,
2136
+ lambda j, d, lst, y: _cleanup_docker_volumes(
2137
+ j,
2138
+ d,
2139
+ lst,
2140
+ y,
2141
+ days=effective_days,
2142
+ match=[t.strip() for t in (match or "").split(",") if t.strip()],
2143
+ ),
2144
+ ),
2091
2145
  "tmp": (
2092
2146
  tmp_cleanup,
2093
2147
  lambda j, d, lst, y: _cleanup_tmp(
@@ -2150,7 +2204,7 @@ def cleanup_services(
2150
2204
  click.echo(
2151
2205
  click.style(
2152
2206
  "Akcje odzyskiwania miejsca (snap/journal/cache/JetBrains/"
2153
- "libvirt/gitive/docker-buildcache/tmp/trash/thumbnails) uruchom jako osobną, pojedynczą akcję.",
2207
+ "libvirt/gitive/docker-buildcache/docker-volumes/tmp/trash/thumbnails) uruchom jako osobną, pojedynczą akcję.",
2154
2208
  fg="red",
2155
2209
  )
2156
2210
  )
@@ -128,7 +128,8 @@ PROVIDER_DEFAULTS = {
128
128
  "description": "Cerebras – najszybsze wnioskowanie na świecie, darmowy tier",
129
129
  },
130
130
  "ollama": {
131
- "base_url": "http://localhost:11434/v1",
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" # nohardcode: allow NOHARDCODE-006 -- fallback when OLLAMA_HOST is unset or empty
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 = base_url or os.environ.get(url_env_key) or pdef["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()
@@ -3,15 +3,16 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ from typing import ClassVar
6
7
 
7
- from fixos.diagnostics.storage_analyzer import StorageItem
8
8
  from fixos.constants import MIN_DOCKER_DANGLING_MB, MIN_PODMAN_MB
9
+ from fixos.diagnostics.storage_analyzer import StorageItem
9
10
 
10
11
 
11
12
  class _ContainerAnalyzerMixin:
12
13
  """Mixin providing container _analyze_* methods for StorageAnalyzer."""
13
14
 
14
- _DOCKER_DF_SECTIONS = {
15
+ _DOCKER_DF_SECTIONS: ClassVar[dict[str, str]] = {
15
16
  "Images space usage:": "images",
16
17
  "Containers space usage:": "containers",
17
18
  "Local Volumes space usage:": "volumes",
@@ -119,11 +120,14 @@ class _ContainerAnalyzerMixin:
119
120
  size_bytes=total_docker,
120
121
  category="containers",
121
122
  risk="medium",
122
- cleanup_command="docker system prune -a --volumes",
123
+ # Never a bulk volume prune: volumes are data and are
124
+ # chosen one by one with `fixos cleanup --docker-volumes`.
125
+ cleanup_command="docker builder prune --force --filter until=168h",
123
126
  description=(
124
127
  f"Docker łącznie: {StorageItem._format_size(total_docker)}. "
125
128
  f"Obrazy: {total_images}, dangling: {dangling_images}. "
126
- "Uwaga: usunie wszystkie nieużywane zasoby."
129
+ "Czyści cache buildów starszy niż 7 dni; wolumeny wybierz "
130
+ "ręcznie przez `fixos cleanup --docker-volumes`."
127
131
  ),
128
132
  )
129
133
  )
@@ -69,9 +69,19 @@ def measure_tree(
69
69
 
70
70
 
71
71
  def measure_batch(
72
- paths: list[Path], exclude: tuple[str, ...] = (), *, native: bool = True
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=120,
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
- batch_result = measure_batch(unique_paths)
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