diff --git a/src/langbot/pkg/api/http/service/maintenance.py b/src/langbot/pkg/api/http/service/maintenance.py index 0c61618c6..cc56d954c 100644 --- a/src/langbot/pkg/api/http/service/maintenance.py +++ b/src/langbot/pkg/api/http/service/maintenance.py @@ -117,8 +117,6 @@ class MaintenanceService: ('logs', Path('data/logs')), ('storage', Path('data/storage')), ('vector_store', Path('data/chroma')), - ('plugins', Path('data/plugins')), - ('mcp', Path('data/mcp')), ('temp', Path('data/temp')), ] else: @@ -126,6 +124,11 @@ class MaintenanceService: roots = [('storage', scoped_storage_path)] sections = await asyncio.to_thread(self._collect_sections, roots) + runtime_processes = await self._runtime_storage_processes( + context, + core_sections=sections, + include_instance_storage=is_oss_singleton, + ) monitoring_counts = await self._monitoring_counts(context) binary_storage = await self._binary_storage_stats(context) @@ -146,6 +149,12 @@ class MaintenanceService: 'log_retention_days': log_retention_days, }, 'sections': sections, + 'processes': runtime_processes, + 'total_size_bytes': sum( + int(process.get('size_bytes') or 0) + for process in runtime_processes + if process.get('status') == 'available' + ), 'database': { 'type': database_type, 'monitoring_counts': monitoring_counts, @@ -158,6 +167,104 @@ class MaintenanceService: 'tasks': self.ap.task_mgr.get_stats() if is_oss_singleton and self.ap.task_mgr else {}, } + async def _runtime_storage_processes( + self, + context: TenantContext, + *, + core_sections: list[dict[str, Any]], + include_instance_storage: bool, + ) -> list[dict[str, Any]]: + core_process = { + 'key': 'langbot', + 'status': 'available', + 'source': 'local_process', + 'size_bytes': sum(int(item.get('size_bytes') or 0) for item in core_sections), + 'directories': [ + { + **item, + 'kind': 'root', + 'error_count': 0, + } + for item in core_sections + ], + } + + plugin_task = self._plugin_runtime_storage_process(include_instance_storage) + box_task = self._box_runtime_storage_process(context) + plugin_process, box_process = await asyncio.gather(plugin_task, box_task) + return [core_process, plugin_process, box_process] + + async def _plugin_runtime_storage_process(self, include_instance_storage: bool) -> dict[str, Any]: + if not include_instance_storage: + return self._unavailable_process( + 'plugin_runtime', + 'not_applicable', + 'Instance-wide Plugin Runtime storage is hidden in multi-Workspace deployments', + ) + + connector = getattr(self.ap, 'plugin_connector', None) + if connector is None or not getattr(connector, 'is_enable_plugin', True): + return self._unavailable_process('plugin_runtime', 'disabled', 'Plugin Runtime is disabled') + runtime_handler = getattr(connector, 'handler', None) + get_analysis = getattr(runtime_handler, 'get_storage_analysis', None) + if not callable(get_analysis): + return self._unavailable_process( + 'plugin_runtime', + 'unavailable', + 'Plugin Runtime does not support storage analysis', + ) + try: + result = await get_analysis() + except Exception as exc: + self.ap.logger.warning(f'Failed to collect Plugin Runtime storage analysis: {exc}') + return self._unavailable_process('plugin_runtime', 'unavailable', str(exc)) + return self._available_process('plugin_runtime', result, source='runtime_rpc') + + async def _box_runtime_storage_process(self, context: TenantContext) -> dict[str, Any]: + box_service = getattr(self.ap, 'box_service', None) + if box_service is None or not getattr(box_service, 'enabled', True): + return self._unavailable_process('box_runtime', 'disabled', 'Box Runtime is disabled') + get_analysis = getattr(box_service, 'get_storage_analysis', None) + if not callable(get_analysis): + return self._unavailable_process( + 'box_runtime', + 'unavailable', + 'Box Runtime does not support storage analysis', + ) + try: + result = await get_analysis(context) + except Exception as exc: + self.ap.logger.warning(f'Failed to collect Box Runtime storage analysis: {exc}') + return self._unavailable_process('box_runtime', 'unavailable', str(exc)) + return self._available_process('box_runtime', result, source='runtime_rpc') + + @staticmethod + def _available_process(key: str, result: Any, *, source: str) -> dict[str, Any]: + payload = result if isinstance(result, dict) else {} + directories = payload.get('directories') + if not isinstance(directories, list): + directories = [] + return { + 'key': key, + 'status': 'available', + 'source': source, + 'size_bytes': int(payload.get('size_bytes') or 0), + 'directories': directories, + 'active_sessions': payload.get('active_sessions'), + 'managed_processes': payload.get('managed_processes'), + } + + @staticmethod + def _unavailable_process(key: str, status: str, error: str) -> dict[str, Any]: + return { + 'key': key, + 'status': status, + 'source': 'runtime_rpc', + 'size_bytes': None, + 'directories': [], + 'error': error, + } + def _collect_sections( self, roots: list[tuple[str, Path | None]], diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index 797914558..68bccddb0 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -1409,6 +1409,16 @@ class BoxService: except Exception: return [] + async def get_storage_analysis(self, context: TenantContext) -> dict: + """Return Workspace-scoped storage measured by the Box Runtime.""" + + if not self._enabled: + raise BoxError('Box runtime is disabled') + if not self._available: + raise BoxError(self._connector_error or 'Box runtime is not available') + execution_context = await self._validated_execution_context(context) + return await self.client.get_storage_analysis(action_context=self._action_context(execution_context)) + def build_spec(self, spec_payload: dict, skip_host_mount_validation: bool = False) -> BoxSpec: spec_payload = dict(spec_payload) spec_payload.setdefault('env', {}) diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 7e59c399c..f23d0eaf0 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -2578,6 +2578,16 @@ class RuntimeConnectionHandler(handler.Handler): timeout=10, ) + async def get_storage_analysis(self) -> dict[str, Any]: + """Return instance-scoped filesystem usage measured by Plugin Runtime.""" + + with self.installation_scope(None): + return await self.call_action( + LangBotToRuntimeAction.GET_STORAGE_ANALYSIS, + {}, + timeout=60, + ) + async def reconcile_plugin_installations( self, installations: tuple[PluginInstallationDesiredState, ...], diff --git a/tests/unit_tests/api/service/test_maintenance_service.py b/tests/unit_tests/api/service/test_maintenance_service.py index 67aa3e66e..b4b00e6fb 100644 --- a/tests/unit_tests/api/service/test_maintenance_service.py +++ b/tests/unit_tests/api/service/test_maintenance_service.py @@ -308,15 +308,98 @@ class TestMaintenanceServiceGetStorageAnalysis: # Execute result = await service.get_storage_analysis(TEST_CONTEXT) - # Verify - all sections present + # Verify - only LangBot-owned sections are scanned by this process. sections = {s['key'] for s in result['sections']} assert 'database' in sections assert 'logs' in sections assert 'storage' in sections assert 'vector_store' in sections - assert 'plugins' in sections - assert 'mcp' in sections assert 'temp' in sections + assert 'plugins' not in sections + assert 'mcp' not in sections + assert [process['key'] for process in result['processes']] == [ + 'langbot', + 'plugin_runtime', + 'box_runtime', + ] + + async def test_get_storage_analysis_aggregates_runtime_owned_directories(self): + plugin_handler = SimpleNamespace( + get_storage_analysis=AsyncMock( + return_value={ + 'size_bytes': 25, + 'directories': [ + { + 'key': 'artifacts', + 'path': 'runtime/data/plugin-runtime/artifacts', + 'kind': 'root', + 'exists': True, + 'size_bytes': 25, + 'file_count': 2, + 'error_count': 0, + } + ], + } + ) + ) + box_service = SimpleNamespace( + enabled=True, + get_storage_analysis=AsyncMock( + return_value={ + 'size_bytes': 40, + 'directories': [ + { + 'key': 'workspace', + 'path': 'box/data/box/default/tenants/test', + 'kind': 'root', + 'exists': True, + 'size_bytes': 40, + 'file_count': 3, + 'error_count': 0, + }, + { + 'key': 'mcp', + 'path': 'box/data/box/default/tenants/test/.mcp', + 'kind': 'detail', + 'parent_key': 'workspace', + 'exists': True, + 'size_bytes': 30, + 'file_count': 2, + 'error_count': 0, + }, + ], + 'active_sessions': 1, + 'managed_processes': 2, + } + ), + ) + ap = SimpleNamespace( + instance_config=SimpleNamespace(data={}), + persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=_create_mock_result(scalar_value=0))), + logger=SimpleNamespace(warning=Mock()), + task_mgr=None, + plugin_connector=SimpleNamespace( + is_enable_plugin=True, + handler=plugin_handler, + ), + box_service=box_service, + ) + service = MaintenanceService(ap) + service._path_size = Mock(return_value=10) + service._file_count = Mock(return_value=1) + service._monitoring_counts = AsyncMock(return_value={}) + service._binary_storage_stats = AsyncMock(return_value={'count': 0, 'size_bytes': 0}) + service._expired_uploaded_candidates = AsyncMock(return_value=[]) + service._expired_log_candidates = Mock(return_value=[]) + + result = await service.get_storage_analysis(TEST_CONTEXT) + + assert result['total_size_bytes'] == 115 + assert result['processes'][1]['directories'][0]['key'] == 'artifacts' + assert result['processes'][2]['directories'][1]['key'] == 'mcp' + assert result['processes'][2]['managed_processes'] == 2 + plugin_handler.get_storage_analysis.assert_awaited_once_with() + box_service.get_storage_analysis.assert_awaited_once_with(TEST_CONTEXT) async def test_get_storage_analysis_postgresql(self): """Handles PostgreSQL database type.""" diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index 942c87432..223591a86 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -430,6 +430,20 @@ async def test_box_service_get_sessions_delegates_to_client(): client.get_sessions.assert_awaited_once() +@pytest.mark.asyncio +async def test_box_service_get_storage_analysis_is_binding_checked_and_scoped(): + client = Mock() + client.get_storage_analysis = AsyncMock(return_value={'total_size_bytes': 42, 'directories': []}) + + service = BoxService(make_app(Mock()), client=client) + service._available = True + + result = await service.get_storage_analysis(_CONTEXT) + + assert result == {'total_size_bytes': 42, 'directories': []} + client.get_storage_analysis.assert_awaited_once_with(action_context=_ACTION_CONTEXT) + + @pytest.mark.asyncio async def test_box_service_relay_connection_is_binding_checked_and_scoped(): app = make_app(Mock()) diff --git a/web/src/app/home/components/storage-analysis-dialog/StorageAnalysisPanel.tsx b/web/src/app/home/components/storage-analysis-dialog/StorageAnalysisPanel.tsx index 833f5e853..52b2ecb6f 100644 --- a/web/src/app/home/components/storage-analysis-dialog/StorageAnalysisPanel.tsx +++ b/web/src/app/home/components/storage-analysis-dialog/StorageAnalysisPanel.tsx @@ -20,6 +20,7 @@ import { import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { Skeleton } from '@/components/ui/skeleton'; import { backendClient } from '@/app/infra/http'; import { PanelToolbar } from '../settings-dialog/panel-layout'; @@ -31,6 +32,24 @@ interface StorageSection { file_count: number; } +interface RuntimeStorageDirectory extends StorageSection { + kind: 'root' | 'detail'; + parent_key?: string; + error_count?: number; + scope?: 'runtime_host' | 'sandbox_sessions'; +} + +interface RuntimeStorageProcess { + key: 'langbot' | 'plugin_runtime' | 'box_runtime'; + status: 'available' | 'unavailable' | 'disabled' | 'not_applicable'; + source: 'local_process' | 'runtime_rpc'; + size_bytes: number | null; + directories: RuntimeStorageDirectory[]; + error?: string; + active_sessions?: number | null; + managed_processes?: number | null; +} + interface CleanupCandidate { key?: string; name?: string; @@ -46,6 +65,8 @@ interface StorageAnalysis { log_retention_days: number; }; sections: StorageSection[]; + processes?: RuntimeStorageProcess[]; + total_size_bytes?: number; database: { type: string; monitoring_counts: Record; @@ -114,7 +135,9 @@ export default function StorageAnalysisPanel({ const totalBytes = useMemo(() => { return ( - analysis?.sections.reduce((sum, item) => sum + item.size_bytes, 0) ?? 0 + analysis?.total_size_bytes ?? + analysis?.sections.reduce((sum, item) => sum + item.size_bytes, 0) ?? + 0 ); }, [analysis]); @@ -168,6 +191,8 @@ export default function StorageAnalysisPanel({ )} + {loading && !analysis && } + {analysis && ( <>
@@ -219,41 +244,28 @@ export default function StorageAnalysisPanel({
-
-

- {t('storageAnalysis.sections')} -

-
- {analysis.sections.map((section) => ( -
-
-
- {t(`storageAnalysis.sectionNames.${section.key}`)} -
-
- {section.path || '-'} -
-
- {section.exists ? ( - - ) : ( - - {t('storageAnalysis.missing')} - - )} -
- {formatBytes(section.size_bytes)} -
-
- {section.file_count} -
-
- ))} -
-
+ {analysis.processes?.length ? ( +
+
+

+ {t('storageAnalysis.processStorage')} +

+

+ {t('storageAnalysis.processStorageDescription')} +

+
+
+ {analysis.processes.map((process) => ( + + ))} +
+
+ ) : ( + + )}