diff --git a/greenfield/.bun-browser-test-timings.json b/greenfield/.bun-browser-test-timings.json index 2b102da05..55c33d11e 100644 --- a/greenfield/.bun-browser-test-timings.json +++ b/greenfield/.bun-browser-test-timings.json @@ -1,162 +1,162 @@ { - "version": 1, "files": { - "src/browser/security/AccountSecurityRoute.test.tsx": 11325, - "src/browser/chat/ChatBrowser.test.tsx": 11072, - "src/browser/jobs/JobsRoute.test.tsx": 8738, - "src/browser/jobs/JobRunBrowser.test.tsx": 6921, - "src/browser/auth/LoginRoute.test.tsx": 5679, - "src/browser/tasks/TaskBoardRoute.test.tsx": 4885, - "src/browser/chat/ChatWorkspace.test.tsx": 4584, - "src/browser/files/WorkspaceFilesView.test.tsx": 4438, - "src/browser/notifications/NotificationCenter.test.tsx": 4116, - "src/browser/monitoring/MonitoringRoutes.test.tsx": 3416, - "src/browser/agents/AgentsRoute.test.tsx": 3333, - "src/browser/jobs/ScheduleDetailForm.test.tsx": 3285, - "src/browser/openClawCron/OpenClawCronSection.test.tsx": 3206, - "src/browser/openClawCron/OpenClawCronBrowser.test.tsx": 2754, - "src/browser/logs/LogsBrowser.test.tsx": 2501, - "src/browser/overview/OverviewAgentsSection.test.tsx": 2368, - "src/browser/files/WorkspaceFilesBrowser.test.tsx": 2365, - "src/browser/ui/Virtualizer.test.tsx": 2315, - "src/browser/files/WorkspaceFileEditorPane.test.tsx": 2241, - "src/browser/logs/LogsView.test.tsx": 2076, - "src/browser/jobs/ScheduleDetailStateDisable.test.tsx": 1931, - "src/browser/files/WorkspaceFilesRoute.test.tsx": 1889, - "src/browser/ui/Select.test.tsx": 1873, - "src/browser/overview/OverviewRoute.test.tsx": 1853, - "src/browser/terminal/TerminalBrowser.test.tsx": 1675, - "src/browser/sessions/GatewaySessionsBrowser.test.tsx": 1597, - "src/browser/auth/AuthenticatedBrowserCacheBoundary.test.tsx": 1296, - "src/browser/ui/JsonViewer.test.tsx": 1273, - "src/browser/main.test.tsx": 1269, - "src/browser/overview/OverviewTasksSection.test.tsx": 1269, - "src/browser/sessions/GatewaySessionsView.test.tsx": 1249, - "src/browser/overview/OverviewReportsSection.test.tsx": 1152, - "src/browser/jobs/ScheduleDetailStateCopy.test.tsx": 1095, - "src/browser/jobs/ScheduleEditor.test.tsx": 1071, - "src/browser/ui/DateTimePicker.test.tsx": 1049, - "src/browser/security/AutomationPrincipalCard.test.tsx": 1044, - "src/browser/ui/SourceViewer.test.tsx": 1012, - "src/browser/chat/ChatAttachmentPicker.test.tsx": 992, - "src/browser/chat/ChatComposer.test.tsx": 977, - "src/browser/auth/AuthenticationBoundary.test.tsx": 960, - "src/browser/terminal/TerminalWorkspace.test.tsx": 949, - "src/browser/ui/TimePicker.test.tsx": 913, - "src/browser/application.test.tsx": 907, - "src/browser/ui/DatePicker.test.tsx": 901, - "src/browser/jobs/ScheduleDetailStateErrors.test.tsx": 881, - "src/browser/jobs/ScheduleDetailStateVersion.test.tsx": 868, - "src/browser/router.test.ts": 847, - "src/browser/auth/AuthenticatedSessionActivity.test.tsx": 843, - "src/browser/overview/OverviewIncidentsSection.test.tsx": 840, - "src/browser/overview/OverviewJobsSection.test.tsx": 794, - "src/browser/overview/OverviewNotificationsSection.test.tsx": 770, - "src/browser/ui/Combobox.test.tsx": 767, - "src/browser/chat/ChatMessageBubble.test.tsx": 762, - "src/browser/auth/useAuthenticatedMutationBoundary.test.tsx": 760, - "src/browser/chat/ChatTranscript.test.tsx": 742, - "src/browser/chat/useChatRuntimeProjection.test.tsx": 719, - "src/browser/jobs/JobQueuePanel.test.tsx": 706, - "src/browser/cache/cacheMutations.test.tsx": 701, - "src/browser/openClawCron/useOpenClawCronRealtimeInvalidation.test.tsx": 700, - "src/browser/overview/OverviewJobsCard.test.tsx": 697, - "src/browser/api/useObservedQueryState.test.tsx": 695, - "src/browser/chat/useChatSpeech.test.tsx": 657, - "src/browser/logs/LogLevelFilter.test.tsx": 650, - "src/browser/jobs/ScheduleDetailStateReplay.test.tsx": 644, - "src/browser/chat/ChatLiveProjection.test.tsx": 619, - "src/browser/sessions/GatewaySessionsRoute.test.tsx": 619, - "src/browser/monitoring/IncidentTable.test.tsx": 608, - "src/browser/cache/CacheStatusTable.test.tsx": 605, - "src/browser/chat/ChatRoute.test.tsx": 580, - "src/browser/ui/ExpandableCard.test.tsx": 576, - "src/browser/jobs/JobRunDetail.test.tsx": 574, - "src/browser/terminal/TerminalRoute.test.tsx": 566, - "src/browser/files/WorkspaceFileTable.test.tsx": 551, - "src/browser/jobs/JobRunTable.test.tsx": 524, - "src/browser/sessions/useGatewaySessionRealtimeInvalidation.test.tsx": 521, - "src/browser/jobs/ScheduleTable.test.tsx": 517, - "src/browser/chat/ChatRuntimeStoreProvider.test.tsx": 513, - "src/browser/overview/OverviewTasksCard.test.tsx": 499, - "src/browser/routes/logs.lazy.test.tsx": 488, - "src/browser/chat/ChatAttachmentPreview.test.tsx": 485, - "src/browser/files/workspaceFileTransfers.test.ts": 484, - "src/browser/jobs/useJobRealtimeInvalidation.test.tsx": 477, - "src/browser/terminal/terminalProtocol.test.ts": 475, - "src/browser/monitoring/useMonitoringRealtimeInvalidation.test.tsx": 473, - "src/browser/overview/OverviewIncidentsCard.test.tsx": 464, - "src/browser/tasks/useTaskRealtimeInvalidation.test.tsx": 464, - "src/browser/overview/OverviewAgentsCard.test.tsx": 462, - "src/browser/ui/DropdownMenu.test.tsx": 456, - "src/browser/sessions/gatewaySessionPresentation.test.ts": 455, - "src/browser/ui/Alert.test.tsx": 455, - "src/browser/logs/LogsRoute.test.tsx": 446, - "src/browser/jobs/jobMutations.test.tsx": 442, - "src/browser/notifications/useNotificationRealtimeInvalidation.test.tsx": 441, - "src/browser/overview/OverviewReportsCard.test.tsx": 431, - "src/browser/api/realtimeHub.test.ts": 417, - "src/browser/overview/SystemMetricsCards.test.tsx": 417, - "src/browser/sessions/gatewaySessionQueries.test.ts": 417, - "src/browser/layout/DashboardShell.test.ts": 415, - "src/browser/jobs/jobQueries.test.ts": 407, - "src/browser/chat/chatQueries.test.ts": 390, - "src/browser/overview/OverviewNotificationsCard.test.tsx": 385, - "src/browser/ui/CopyTextButton.test.tsx": 385, - "src/browser/chat/chatAttachments.test.ts": 384, - "src/browser/files/workspaceFilePresentation.test.ts": 384, - "src/browser/cache/useCacheRealtimeInvalidation.test.tsx": 383, - "src/browser/api/trpcClient.test.ts": 382, - "src/browser/chat/chatViewProjection.test.ts": 378, - "src/browser/ui/DataTable.test.tsx": 376, - "src/browser/ui/RadioGroup.test.tsx": 376, - "src/browser/security/webauthn/webauthnClient.test.ts": 373, - "src/browser/ui/Popover.test.tsx": 373, - "src/browser/api/useRealtimeQueryInvalidation.test.tsx": 372, - "src/browser/cache/cacheQueries.test.ts": 372, - "src/browser/lib/formatMeasurements.test.ts": 368, - "src/browser/tasks/taskQueries.test.ts": 367, - "src/browser/ui/Tabs.test.tsx": 365, - "src/browser/logs/logLinePresentation.test.ts": 360, + "src/browser/chat/ChatBrowser.test.tsx": 14089, + "src/browser/security/AccountSecurityRoute.test.tsx": 12008, + "src/browser/jobs/JobsRoute.test.tsx": 6784, + "src/browser/jobs/JobRunBrowser.test.tsx": 5861, + "src/browser/logs/LogsView.test.tsx": 4778, + "src/browser/chat/ChatWorkspace.test.tsx": 4539, + "src/browser/files/WorkspaceFilesView.test.tsx": 3836, + "src/browser/ui/Virtualizer.test.tsx": 3652, + "src/browser/notifications/NotificationCenter.test.tsx": 3129, + "src/browser/tasks/TaskBoardRoute.test.tsx": 2902, + "src/browser/monitoring/MonitoringRoutes.test.tsx": 2756, + "src/browser/openClawCron/OpenClawCronSection.test.tsx": 2646, + "src/browser/auth/LoginRoute.test.tsx": 2545, + "src/browser/ui/JsonViewer.test.tsx": 2291, + "src/browser/openClawCron/OpenClawCronBrowser.test.tsx": 2097, + "src/browser/overview/OverviewAgentsSection.test.tsx": 2081, + "src/browser/logs/LogsBrowser.test.tsx": 2056, + "src/browser/files/WorkspaceFilesRoute.test.tsx": 2037, + "src/browser/sessions/GatewaySessionsView.test.tsx": 1974, + "src/browser/agents/AgentsRoute.test.tsx": 1906, + "src/browser/jobs/ScheduleEditor.test.tsx": 1876, + "src/browser/overview/OverviewReportsSection.test.tsx": 1603, + "src/browser/files/WorkspaceFileEditorPane.test.tsx": 1558, + "src/browser/sessions/GatewaySessionsBrowser.test.tsx": 1532, + "src/browser/chat/ChatAttachmentPicker.test.tsx": 1521, + "src/browser/jobs/ScheduleDetailStateDisable.test.tsx": 1507, + "src/browser/overview/OverviewRoute.test.tsx": 1482, + "src/browser/overview/OverviewTasksSection.test.tsx": 1447, + "src/browser/auth/AuthenticatedBrowserCacheBoundary.test.tsx": 1444, + "src/browser/ui/DropdownMenu.test.tsx": 1443, + "src/browser/chat/ChatComposer.test.tsx": 1417, + "src/browser/ui/DateTimePicker.test.tsx": 1405, + "src/browser/ui/SourceViewer.test.tsx": 1401, + "src/browser/cache/cacheMutations.test.tsx": 1370, + "src/browser/jobs/ScheduleDetailForm.test.tsx": 1338, + "src/browser/files/WorkspaceFilesBrowser.test.tsx": 1310, + "src/browser/terminal/TerminalBrowser.test.tsx": 1300, + "src/browser/jobs/ScheduleDetailStateVersion.test.tsx": 1272, + "src/browser/jobs/ScheduleDetailStateCopy.test.tsx": 1215, + "src/browser/overview/OverviewIncidentsSection.test.tsx": 1193, + "src/browser/chat/ChatRuntimeStoreProvider.test.tsx": 1162, + "src/browser/application.test.tsx": 1139, + "src/browser/auth/AuthenticationBoundary.test.tsx": 1088, + "src/browser/ui/Alert.test.tsx": 1085, + "src/browser/security/AutomationPrincipalCard.test.tsx": 1075, + "src/browser/logs/LogsRoute.test.tsx": 1067, + "src/browser/main.test.tsx": 993, + "src/browser/api/realtimeHub.test.ts": 963, + "src/browser/auth/AuthenticatedSessionActivity.test.tsx": 907, + "src/browser/chat/ChatMessageBubble.test.tsx": 890, + "src/browser/terminal/TerminalWorkspace.test.tsx": 871, + "src/browser/router.test.ts": 858, + "src/browser/auth/useAuthenticatedMutationBoundary.test.tsx": 842, + "src/browser/overview/OverviewJobsSection.test.tsx": 827, + "src/browser/jobs/ScheduleDetailStateReplay.test.tsx": 775, + "src/browser/jobs/JobRunTable.test.tsx": 755, + "src/browser/chat/ChatAttachmentPreview.test.tsx": 728, + "src/browser/chat/useChatRuntimeProjection.test.tsx": 727, + "src/browser/ui/Combobox.test.tsx": 712, + "src/browser/ui/TimePicker.test.tsx": 712, + "src/browser/ui/DatePicker.test.tsx": 698, + "src/browser/tasks/useTaskRealtimeInvalidation.test.tsx": 697, + "src/browser/overview/OverviewAgentsCard.test.tsx": 693, + "src/browser/jobs/JobRunDetail.test.tsx": 677, + "src/browser/chat/useChatSpeech.test.tsx": 675, + "src/browser/jobs/ScheduleDetailStateErrors.test.tsx": 669, + "src/browser/jobs/jobMutations.test.tsx": 661, + "src/browser/chat/ChatLiveProjection.test.tsx": 659, + "src/browser/ui/Select.test.tsx": 653, + "src/browser/monitoring/useMonitoringRealtimeInvalidation.test.tsx": 649, + "src/browser/notifications/useNotificationRealtimeInvalidation.test.tsx": 618, + "src/browser/chat/ChatTranscript.test.tsx": 605, + "src/browser/routes/logs.lazy.test.tsx": 593, + "src/browser/chat/chatAttachments.test.ts": 592, + "src/browser/cache/CacheStatusTable.test.tsx": 590, + "src/browser/files/workspaceFileTransfers.test.ts": 579, + "src/browser/openClawCron/useOpenClawCronRealtimeInvalidation.test.tsx": 576, + "src/browser/chat/chatQueries.test.ts": 562, + "src/browser/sessions/gatewaySessionPresentation.test.ts": 544, + "src/browser/jobs/useJobRealtimeInvalidation.test.tsx": 534, + "src/browser/overview/OverviewIncidentsCard.test.tsx": 534, + "src/browser/overview/OverviewNotificationsSection.test.tsx": 524, + "src/browser/overview/OverviewTasksCard.test.tsx": 524, + "src/browser/files/WorkspaceFileTable.test.tsx": 515, + "src/browser/ui/ExpandableCard.test.tsx": 484, + "src/browser/jobs/JobQueuePanel.test.tsx": 474, + "src/browser/jobs/ScheduleTable.test.tsx": 456, + "src/browser/ui/Switch.test.tsx": 446, + "src/browser/ui/Tabs.test.tsx": 446, + "src/browser/ui/MetricCard.test.tsx": 445, + "src/browser/chat/ChatRoute.test.tsx": 443, + "src/browser/ui/Button.test.tsx": 433, + "src/browser/ui/RadioGroup.test.tsx": 428, + "src/browser/chat/chatRuntimeStore.test.ts": 422, + "src/browser/sessions/GatewaySessionsRoute.test.tsx": 419, + "src/browser/ui/CopyTextButton.test.tsx": 418, + "src/browser/logs/LogLevelFilter.test.tsx": 412, + "src/browser/overview/OverviewReportsCard.test.tsx": 397, + "src/browser/files/workspaceFilePresentation.test.ts": 396, + "src/browser/overview/OverviewJobsCard.test.tsx": 390, + "src/browser/ui/Popover.test.tsx": 389, + "src/browser/terminal/terminalProtocol.test.ts": 382, + "src/browser/ui/DataTable.test.tsx": 379, + "src/browser/ui/Fieldset.test.tsx": 375, + "src/browser/monitoring/monitoringQueries.test.ts": 373, + "src/browser/monitoring/IncidentTable.test.tsx": 370, + "src/browser/chat/chatViewProjection.test.ts": 364, + "src/browser/cache/useCacheRealtimeInvalidation.test.tsx": 358, + "src/browser/chat/chatContractAdapter.test.ts": 358, "src/browser/chat/useChatRealtimeInvalidation.test.tsx": 358, - "src/browser/ui/Button.test.tsx": 356, "src/browser/notifications/notificationMutations.test.ts": 354, - "src/browser/ui/ExternalLink.test.tsx": 353, - "src/browser/auth/authQueries.test.ts": 352, - "src/browser/security/issuedAutomationToken.test.ts": 352, - "src/browser/cache/SystemHostCard.test.tsx": 350, - "src/browser/cache/cachePresentation.test.ts": 345, - "src/browser/terminal/terminalEmulator.test.ts": 339, - "src/browser/chat/chatMutations.test.ts": 338, - "src/browser/jobs/jobRouteSearch.test.ts": 337, - "src/browser/logs/logQueries.test.ts": 336, - "src/browser/monitoring/monitoringQueries.test.ts": 336, - "src/browser/data/dashboardCollections.test.ts": 335, - "src/browser/files/workspaceFileQueries.test.ts": 335, - "src/browser/chat/chatRuntimeStore.test.ts": 333, - "src/browser/chat/chatPostMutationObservations.test.ts": 329, - "src/browser/api/realtimeClient.test.ts": 327, - "src/browser/jobs/scheduleEditorForm.test.ts": 320, - "src/browser/ui/LoadingState.test.tsx": 317, - "src/browser/ui/Switch.test.tsx": 317, - "src/browser/test/setup.test.ts": 316, - "src/browser/chat/chatContractAdapter.test.ts": 303, - "src/browser/notifications/notificationQueries.test.ts": 302, - "src/browser/overview/systemMetricsQueries.test.ts": 299, - "src/browser/tasks/taskEditorForm.test.ts": 299, - "src/browser/notifications/notificationCollection.test.ts": 293, - "src/browser/chat/chatRouteSearch.test.ts": 292, - "src/browser/ui/Fieldset.test.tsx": 289, - "src/browser/lib/formatDateTime.test.ts": 286, - "src/browser/ui/MetricCard.test.tsx": 286, - "src/browser/api/trpcError.test.ts": 282, - "src/browser/logs/logClient.test.ts": 282, - "src/browser/chat/chatSessionTokenPresentation.test.ts": 278, - "src/browser/layout/dashboardSystemStatus.test.ts": 277, - "src/browser/chat/chatLocalPreferences.test.ts": 268, - "src/browser/tasks/TaskBoard.test.ts": 267, - "src/browser/overview/systemMetricsPresentation.test.ts": 266, - "src/browser/chat/ChatBrowserState.test.ts": 265, - "src/browser/chat/chatSessionPicker.test.ts": 259 - } + "src/browser/terminal/terminalEmulator.test.ts": 354, + "src/browser/ui/LoadingState.test.tsx": 353, + "src/browser/overview/SystemMetricsCards.test.tsx": 351, + "src/browser/api/trpcClient.test.ts": 348, + "src/browser/layout/dashboardSystemStatus.test.ts": 344, + "src/browser/sessions/useGatewaySessionRealtimeInvalidation.test.tsx": 344, + "src/browser/notifications/notificationCollection.test.ts": 342, + "src/browser/overview/OverviewNotificationsCard.test.tsx": 340, + "src/browser/terminal/TerminalRoute.test.tsx": 338, + "src/browser/logs/logLinePresentation.test.ts": 333, + "src/browser/tasks/TaskBoard.test.ts": 332, + "src/browser/notifications/notificationQueries.test.ts": 331, + "src/browser/api/realtimeClient.test.ts": 330, + "src/browser/ui/ExternalLink.test.tsx": 328, + "src/browser/test/setup.test.ts": 327, + "src/browser/cache/SystemHostCard.test.tsx": 326, + "src/browser/logs/logClient.test.ts": 326, + "src/browser/cache/cacheQueries.test.ts": 322, + "src/browser/files/workspaceFileQueries.test.ts": 320, + "src/browser/lib/formatDateTime.test.ts": 320, + "src/browser/overview/systemMetricsQueries.test.ts": 319, + "src/browser/chat/chatMutations.test.ts": 318, + "src/browser/tasks/taskEditorForm.test.ts": 317, + "src/browser/jobs/scheduleEditorForm.test.ts": 315, + "src/browser/jobs/jobQueries.test.ts": 314, + "src/browser/api/useRealtimeQueryInvalidation.test.tsx": 311, + "src/browser/data/dashboardCollections.test.ts": 311, + "src/browser/security/webauthn/webauthnClient.test.ts": 309, + "src/browser/sessions/gatewaySessionQueries.test.ts": 300, + "src/browser/chat/chatPostMutationObservations.test.ts": 299, + "src/browser/api/trpcError.test.ts": 297, + "src/browser/chat/chatLocalPreferences.test.ts": 296, + "src/browser/cache/cachePresentation.test.ts": 292, + "src/browser/chat/chatSessionTokenPresentation.test.ts": 290, + "src/browser/chat/ChatBrowserState.test.ts": 288, + "src/browser/logs/logQueries.test.ts": 286, + "src/browser/security/issuedAutomationToken.test.ts": 285, + "src/browser/chat/chatSessionPicker.test.ts": 280, + "src/browser/api/useObservedQueryState.test.tsx": 279, + "src/browser/jobs/jobRouteSearch.test.ts": 277, + "src/browser/overview/systemMetricsPresentation.test.ts": 268, + "src/browser/chat/chatRouteSearch.test.ts": 261, + "src/browser/tasks/taskQueries.test.ts": 261, + "src/browser/layout/DashboardShell.test.ts": 242, + "src/browser/auth/authQueries.test.ts": 240, + "src/browser/lib/formatMeasurements.test.ts": 226 + }, + "version": 1 } diff --git a/greenfield/.bun-test-timings.json b/greenfield/.bun-test-timings.json index 79a6112a6..6627b1261 100644 --- a/greenfield/.bun-test-timings.json +++ b/greenfield/.bun-test-timings.json @@ -1,369 +1,371 @@ { - "version": 1, "files": { - "scripts/delivery/productionReleaseActivation.test.ts": 55160, - "src/test/integration/delivery/productionReleaseLifecycle.test.ts": 46260, - "src/server/domains/chat/service.test.ts": 28970, - "scripts/delivery/buildBrowser.test.ts": 25287, - "scripts/sourceBoundaries/policy.test.ts": 23016, - "src/server/test/system/serverMfaAuthentication.test.ts": 14310, - "src/worker/files/descriptorWorkspaceFileStructuralWriter.test.ts": 13400, - "scripts/sourceBoundaries/lintConfiguration.test.ts": 12788, - "scripts/delivery/productionReleasePublication.test.ts": 10458, - "scripts/delivery/installProductionSystemdUnits.test.ts": 9765, - "scripts/delivery/systemdProductionServices.test.ts": 8849, - "src/server/database/migrations/jobsSchema.test.ts": 7139, - "src/server/domains/chat/repository.test.ts": 7095, - "scripts/delivery/releaseIdentity.test.ts": 5399, - "src/server/domains/jobs/repository.test.ts": 5190, - "src/server/test/system/serverAutomationSecurityLeaseInvalidation.test.ts": 4894, - "src/test/integration/build/frontendBuildScenario.test.ts": 4724, - "src/server/test/system/serverWebAuthnAuthentication.test.ts": 4424, - "src/server/domains/monitoring/catalogService.test.ts": 4245, - "src/test/integration/transport/topology/rollingReleaseSse.test.ts": 3779, - "src/test/integration/transport/trpc/trpcFetchSse.test.ts": 3551, - "src/server/test/system/serverGatewayCredentialVerification.test.ts": 3422, - "src/server/database/runtime/databaseService.test.ts": 3194, - "src/server/domains/security/mfa/loginLifecycle.webAuthn.test.ts": 2658, - "scripts/delivery/buildRelease.test.ts": 2574, - "src/server/domains/jobs/service.test.ts": 2501, - "src/server/test/system/serverFoundation.test.ts": 2270, - "src/server/test/system/serverAutomationSecurity.test.ts": 2116, - "src/server/domains/security/mfa/accountLifecycle.webAuthn.test.ts": 2105, - "src/test/integration/transport/topology/httpsReverseProxy.test.ts": 2039, - "src/server/database/runtime/databaseSnapshot.test.ts": 2034, - "src/test/integration/resources/pausedTlsSseClient.test.ts": 1942, - "src/test/integration/shutdown/completeShutdownScenario.test.ts": 1794, - "src/server/domains/security/automation/lifecyclePrincipal.test.ts": 1775, - "src/server/test/system/serverRealtime.test.ts": 1677, - "src/server/platform/realtime/eventPumpSubscriptionCancellation.test.ts": 1664, - "src/server/test/system/serverAutomationSecurityLostResponse.test.ts": 1600, - "src/server/domains/cache/repository.test.ts": 1575, - "scripts/delivery/databaseTransitionFilesystem.test.ts": 1567, - "src/server/domains/security/authenticationLifecycle.bootstrap.test.ts": 1538, - "src/server/domains/security/automation/lifecycleRepository.test.ts": 1502, - "src/server/domains/security/authenticationLifecycle.rateLimit.test.ts": 1469, - "src/server/domains/security/mfa/loginLifecycle.totp.test.ts": 1436, - "src/server/domains/security/authenticationLifecycle.sessions.test.ts": 1387, - "src/server/platform/gateway/persistentGatewayTransport.test.ts": 1386, - "src/server/platform/runtime/applicationRuntime.test.ts": 1386, - "src/server/domains/agents/service.test.ts": 1369, - "src/server/database/migrations/migrationLedgerValidation.test.ts": 1363, - "src/server/platform/runtime/dashboardApplicationRuntime.test.ts": 1359, - "src/server/domains/security/mfa/lifecycleRepository.test.ts": 1313, - "src/server/platform/realtime/eventPumpSubscriptionBackpressure.test.ts": 1295, - "src/server/domains/security/authenticationLifecycle.password.test.ts": 1270, - "src/server/platform/realtime/eventPumpSubscriptionReplay.test.ts": 1241, - "src/server/domains/tasks/service.test.ts": 1231, - "src/server/domains/security/requestAuthenticationAutomation.test.ts": 1222, - "src/test/integration/outbox/sqliteOutboxScenario.test.ts": 1216, - "src/test/integration/websocket/nativeWebSocketTransport.test.ts": 1195, - "src/server/domains/jobs/coordinator.test.ts": 1181, - "src/server/database/migrations/monitoringSchema.test.ts": 1174, - "src/app/dashboardServer.test.ts": 1171, - "src/server/database/migrations/migrationGraph.test.ts": 1097, - "src/server/domains/tasks/procedures.test.ts": 1081, - "src/server/domains/monitoring/serviceOrdering.test.ts": 1057, - "scripts/sourceBoundaries/sourceDiscovery.test.ts": 1052, - "src/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts": 1040, - "src/server/domains/security/mfa/accountLifecycle.proofs.test.ts": 1029, - "src/server/domains/security/automation/lifecycleCredential.test.ts": 1004, - "src/server/platform/realtime/eventPumpPolling.test.ts": 969, - "src/server/domains/security/requestAuthenticationRepository.test.ts": 931, - "src/server/database/migrations/auditEventsSchema.test.ts": 885, - "src/server/domains/security/mfa/accountLifecycle.maintenance.test.ts": 869, - "src/server/domains/security/authenticationLifecycleRepository.test.ts": 850, - "src/server/domains/monitoring/serviceBoundary.test.ts": 827, - "src/server/domains/security/mfa/loginLifecycle.recovery.test.ts": 827, - "src/server/domains/security/requestAuthenticationSession.test.ts": 815, - "src/server/domains/security/mfa/accountLifecycle.factors.test.ts": 801, - "src/server/domains/security/password.test.ts": 792, - "src/server/test/system/serverAuthenticationTransport.test.ts": 774, - "src/server/domains/monitoring/procedures.test.ts": 772, - "src/server/domains/chat/reducer.test.ts": 765, - "src/server/domains/cache/procedures.test.ts": 758, - "src/server/domains/agents/procedures.test.ts": 747, - "src/server/database/migrations/webauthnLifecycleSchema.test.ts": 740, - "src/server/platform/realtime/eventStore.test.ts": 723, - "src/server/database/migrations/authenticationRateLimitSchema.test.ts": 721, - "src/server/domains/jobs/workerSystem.test.ts": 692, - "src/server/platform/gateway/persistentGatewayProtocol.test.ts": 690, - "src/server/domains/security/securityAuditLifecycle.test.ts": 647, - "src/server/domains/security/mfa/procedures.test.ts": 611, - "src/server/domains/security/authenticationWorkGate.webAuthn.test.ts": 601, - "src/app/dashboardServerProcess.test.ts": 580, - "scripts/delivery/buildProcesses.test.ts": 554, - "src/server/database/migrations/securityIdentitySchema.baseline.test.ts": 514, - "src/server/domains/monitoring/serviceLifecycle.test.ts": 495, - "src/server/platform/files/descriptorWorkspaceFileUploadSpool.test.ts": 468, - "src/server/test/system/serverAuthenticationResponses.test.ts": 465, - "src/server/platform/realtime/eventPumpServicePolling.test.ts": 459, - "scripts/delivery/installLogMaintenanceProvisioning.test.ts": 454, - "scripts/documentation/artifacts.test.ts": 414, - "src/server/platform/gateway/persistentGatewayChatProvider.test.ts": 414, - "scripts/sourceBoundaries/boundaryConfiguration.test.ts": 396, - "src/test/integration/shutdown/shutdownServiceResources.test.ts": 396, - "src/server/platform/realtime/eventPumpSubscriptionRetention.test.ts": 389, - "src/server/test/system/serverShutdown.test.ts": 388, - "src/test/parity/parityInventory.test.ts": 375, - "scripts/delivery/productionStateFilesystem.test.ts": 350, - "src/server/platform/files/descriptorWorkspaceFileReader.test.ts": 343, - "src/server/platform/configuration/webConfiguration.test.ts": 342, - "src/server/domains/security/authenticationWorkGate.test.ts": 337, - "src/server/domains/security/procedures.test.ts": 333, - "scripts/delivery/productionActivationJournal.test.ts": 332, - "src/server/database/migrations/securityIdentitySchema.browser.test.ts": 331, - "src/server/platform/realtime/eventPumpServiceLifecycle.test.ts": 326, - "scripts/delivery/productionRuntime.test.ts": 325, - "src/server/domains/tasks/taskNotificationQueue.test.ts": 322, - "src/server/database/migrations/taskSchema.test.ts": 320, - "src/server/domains/cache/service.test.ts": 319, - "src/server/platform/realtime/eventPumpServiceFairness.test.ts": 319, - "scripts/sourceBoundaries/importGraph.test.ts": 315, - "scripts/sourceBoundaries/importTargetValidation.test.ts": 313, - "src/server/database/migrations/migrationApplicationTime.test.ts": 308, - "src/test/integration/openclaw/sourceAudit.test.ts": 307, - "src/worker/logs/managedLogRotation.test.ts": 306, - "src/server/database/migrations/mfaLifecycleSchema.test.ts": 303, - "src/server/domains/tasks/repositoryReader.test.ts": 302, - "src/server/domains/security/authPendingMfaRoutes.webAuthn.test.ts": 301, - "src/server/trpc/appRouter.test.ts": 300, - "src/server/domains/security/automation/procedures.test.ts": 298, - "src/server/domains/realtime/transport.test.ts": 296, - "scripts/runTestSuite.test.ts": 292, - "scripts/sourceBoundaries/checkerIntegration.test.ts": 290, - "src/server/platform/gateway/gatewayCredentialVerifier.test.ts": 287, - "src/server/domains/security/mfa/loginLifecycle.pending.test.ts": 285, - "src/server/domains/realtime/procedures.test.ts": 282, - "src/server/database/migrations/realtimeSchema.test.ts": 281, - "src/server/database/migrations/loadVerifiedMigrations.test.ts": 280, - "src/server/platform/observability/effectLogger.test.ts": 279, - "src/server/trpc/context.test.ts": 273, - "src/app/trpcHttpHandler.test.ts": 269, - "src/server/domains/chat/procedures.test.ts": 267, - "src/server/domains/jobs/procedures.test.ts": 266, - "src/server/trpc/appRouterOptionalFeatureServices.test.ts": 266, - "src/server/database/migrations/securityIdentitySchema.automation.test.ts": 260, - "src/server/database/migrations/agentTaskRunsSchema.test.ts": 257, - "scripts/delivery/deploymentLease.test.ts": 251, - "src/server/domains/security/securityAuditProcedures.test.ts": 251, - "src/server/domains/files/service.test.ts": 249, - "src/server/domains/system/procedures.test.ts": 248, - "src/server/platform/realtime/renewableStreamLease.test.ts": 248, - "src/server/domains/security/authenticationLifecycle.login.test.ts": 247, - "scripts/delivery/productionActivationState.test.ts": 244, - "src/server/database/migrations/jsonObjectConstraints.test.ts": 243, - "src/server/rawHttp/chatSpeech.test.ts": 243, - "src/server/trpc/procedureErrorPolicy.test.ts": 243, - "src/server/platform/realtime/eventPumpServiceSubscription.test.ts": 241, - "src/server/domains/files/procedures.test.ts": 235, - "src/app/worker.test.ts": 227, - "src/server/test/contracts/trpcErrors.test.ts": 220, - "src/server/domains/openClawCron/sqliteIntentStore.test.ts": 219, - "src/server/platform/chat/elevenLabsSpeechProvider.test.ts": 219, - "src/server/test/contracts/superjsonTransport.test.ts": 218, - "src/server/platform/gateway/persistentGatewayRealtimeBridge.test.ts": 217, - "src/server/platform/logs/safeLogReader.test.ts": 214, - "src/server/platform/realtime/eventPumpContract.test.ts": 205, - "src/server/platform/gateway/persistentOpenClawCronProvider.test.ts": 197, - "src/worker/logs/logMaintenanceAvailabilityPublisher.test.ts": 196, - "src/server/domains/gatewaySessions/procedures.test.ts": 194, - "src/server/platform/gateway/persistentGatewaySessionsProvider.test.ts": 191, - "src/server/domains/jobs/workerRuntime.test.ts": 187, - "src/server/domains/openClawCron/procedures.test.ts": 187, - "src/server/domains/gatewaySessions/controlAuditStore.test.ts": 186, - "src/server/platform/terminal/rootRegistry.test.ts": 183, - "src/server/platform/chat/inMemoryChatAttachmentStore.test.ts": 180, - "src/server/trpc/trpc.test.ts": 179, - "src/server/rawHttp/chatMedia.test.ts": 173, - "src/test/support/asyncCleanupStack.test.ts": 170, - "src/test/integration/resources/runSseMemoryEvidence.test.ts": 168, - "scripts/delivery/productionDeliveryFilesystem.test.ts": 163, - "src/server/database/validation/rowSchemaIntegration.test.ts": 163, - "src/worker/taskNotifications.test.ts": 162, - "scripts/delivery/activateProductionRelease.test.ts": 158, - "src/app/databaseMaintenance.test.ts": 158, - "src/server/platform/configuration/workerConfiguration.test.ts": 158, - "src/server/database/validation/realtimeEvents.test.ts": 157, - "src/server/domains/openClawCron/operationAudit.test.ts": 156, - "src/server/domains/files/jobScheduler.test.ts": 153, - "src/server/domains/realtime/errors.test.ts": 150, - "src/server/domains/openClawTasks/realtime.test.ts": 148, - "src/server/platform/chat/chatSpeechRecording.test.ts": 144, - "src/server/domains/monitoring/testSupport/services.test.ts": 141, - "src/server/database/runtime/databasePath.test.ts": 140, - "src/server/platform/errors/safeFailure.test.ts": 138, - "src/server/platform/gateway/persistentGatewayOpenClawTasksProvider.test.ts": 138, - "scripts/delivery/buildAdmission.test.ts": 135, - "src/server/platform/logs/sourceCatalog.test.ts": 134, - "scripts/buildSourceIdentity.test.ts": 133, - "src/server/domains/jobs/scheduleTime.test.ts": 131, - "src/test/integration/resourceBudgets/resourceBudgetOrchestration.test.ts": 130, - "src/server/database/validation/rowSchemas.test.ts": 126, - "src/contracts/automationSecurity.test.ts": 125, - "scripts/documentation/jsonSchema.test.ts": 124, - "src/server/domains/chat/history.test.ts": 124, - "src/server/domains/jobs/actionExecutors.test.ts": 120, - "src/server/domains/openClawCron/service.test.ts": 120, - "src/server/domains/gatewayConnection/procedures.test.ts": 118, - "src/server/domains/security/mfa/webauthn/adapter.test.ts": 116, - "src/shared/dateTime.test.ts": 115, - "src/app/trpcRequestPolicy.test.ts": 111, - "src/server/database/schema/webauthnPersistence.test.ts": 110, - "src/contracts/jobRealtime.test.ts": 106, - "src/worker/terminal/ptyProcess.test.ts": 106, - "src/server/domains/openClawTasks/service.test.ts": 103, - "src/worker/terminal/workerTerminalBrokerLifecycle.test.ts": 102, - "src/contracts/chatModel.test.ts": 101, - "src/server/platform/release/runtimeRelease.test.ts": 101, - "src/server/domains/monitoring/normalization.test.ts": 100, - "src/server/rawHttp/pendingLoginCookie.test.ts": 98, - "src/server/database/validation/cacheEntries.test.ts": 96, - "src/worker/files/workspaceFileReplaceIntent.test.ts": 94, - "src/worker/logs/logMaintenanceExecutor.test.ts": 93, - "src/server/platform/logs/logMaintenanceAvailability.test.ts": 91, - "src/contracts/accountSecurity.test.ts": 90, - "src/contracts/agents.test.ts": 88, - "src/contracts/openClawCron.test.ts": 88, - "src/server/database/validation/auditEvents.test.ts": 88, - "src/server/database/validation/taskRows.test.ts": 88, - "src/server/domains/gatewaySessions/service.test.ts": 88, - "src/server/domains/tasks/taskNotification.test.ts": 88, - "src/server/platform/observability/structuredLogger.test.ts": 88, - "src/contracts/auth.test.ts": 87, - "src/server/database/schema/cacheEntries.test.ts": 87, - "src/test/integration/files/boundedFile.test.ts": 86, - "src/contracts/contractRegistry.test.ts": 85, - "scripts/delivery/releaseArtifactInventory.test.ts": 82, - "src/server/database/validation/monitorRuns.test.ts": 82, - "src/server/domains/jobs/sideEffects.test.ts": 82, - "src/contracts/events.test.ts": 80, - "src/server/domains/files/rawHttp.test.ts": 80, - "src/contracts/cache.test.ts": 77, - "src/server/domains/jobs/logMaintenanceQueue.test.ts": 77, - "src/server/domains/logs/service.test.ts": 77, - "src/test/integration/resources/unitIdentity.test.ts": 77, - "src/contracts/jobModel.test.ts": 76, - "src/server/database/validation/incidentObservations.test.ts": 75, - "src/server/domains/agents/gatewayAvailability.test.ts": 75, - "src/server/domains/security/recentAuthentication.test.ts": 75, - "src/test/setup.test.ts": 74, - "src/worker/files/workspaceFileRootConfiguration.test.ts": 74, - "src/server/database/validation/automationCredentials.test.ts": 73, - "src/server/database/validation/authChallenges.test.ts": 72, - "src/server/domains/realtime/authenticationLeaseStream.test.ts": 72, - "src/server/platform/logs/redaction.test.ts": 72, - "src/contracts/cacheRealtime.test.ts": 71, - "src/server/database/validation/authPendingLogins.test.ts": 70, - "src/server/database/validation/authSessions.test.ts": 70, - "src/server/database/validation/automationPrincipals.test.ts": 70, - "src/server/domains/jobs/actionRegistry.test.ts": 70, - "src/server/database/validation/users.test.ts": 69, - "src/server/database/validation/authRateLimitBuckets.test.ts": 68, - "src/server/platform/chat/inMemoryChatMediaReferences.test.ts": 68, - "src/server/rawHttp/frontendAssets.test.ts": 68, - "src/server/platform/observability/projectFileLogSink.test.ts": 67, - "src/server/rawHttp/terminalSocket.test.ts": 67, - "src/worker/terminal/terminalSessionBroker.test.ts": 67, - "src/contracts/chatSpeech.test.ts": 65, - "src/server/database/validation/userTotpFactors.test.ts": 65, - "src/server/domains/cache/systemHostProvider.test.ts": 65, - "src/server/platform/files/openClawFileRootConfiguration.test.ts": 65, - "src/test/integration/resources/resourcePolicy.test.ts": 65, - "src/contracts/schedules.test.ts": 64, - "src/contracts/jobs.test.ts": 63, - "src/server/domains/gatewaySessions/controlAudit.test.ts": 63, - "src/contracts/securityAudit.test.ts": 62, - "src/server/domains/security/authenticationSession.test.ts": 62, + "scripts/delivery/productionReleaseActivation.test.ts": 48397, + "src/test/integration/delivery/productionReleaseLifecycle.test.ts": 48086, + "src/server/domains/chat/service.test.ts": 24931, + "scripts/delivery/buildBrowser.test.ts": 20914, + "scripts/sourceBoundaries/policy.test.ts": 16853, + "scripts/sourceBoundaries/lintConfiguration.test.ts": 12593, + "src/worker/files/descriptorWorkspaceFileStructuralWriter.test.ts": 9864, + "scripts/delivery/installProductionSystemdUnits.test.ts": 8995, + "src/server/test/system/serverMfaAuthentication.test.ts": 8526, + "scripts/delivery/productionReleasePublication.test.ts": 7400, + "scripts/delivery/releaseIdentity.test.ts": 6488, + "scripts/delivery/systemdProductionServices.test.ts": 6241, + "src/server/test/system/serverAutomationSecurityLeaseInvalidation.test.ts": 5039, + "src/server/database/migrations/jobsSchema.test.ts": 4571, + "src/test/integration/transport/topology/rollingReleaseSse.test.ts": 4288, + "scripts/delivery/buildRelease.test.ts": 4065, + "src/server/test/system/serverWebAuthnAuthentication.test.ts": 3886, + "src/server/domains/chat/repository.test.ts": 3472, + "src/test/integration/transport/trpc/trpcFetchSse.test.ts": 3386, + "src/server/database/runtime/databaseService.test.ts": 3374, + "src/server/domains/jobs/repository.test.ts": 3186, + "src/test/integration/build/frontendBuildScenario.test.ts": 3073, + "src/server/domains/security/automation/lifecyclePrincipal.test.ts": 2335, + "src/server/domains/security/mfa/loginLifecycle.webAuthn.test.ts": 2238, + "src/server/domains/security/authenticationLifecycle.bootstrap.test.ts": 2221, + "src/test/integration/resources/pausedTlsSseClient.test.ts": 2118, + "src/server/test/system/serverAutomationSecurity.test.ts": 1973, + "src/server/domains/security/mfa/accountLifecycle.webAuthn.test.ts": 1955, + "src/server/domains/security/authenticationLifecycle.rateLimit.test.ts": 1864, + "src/server/domains/jobs/service.test.ts": 1862, + "src/test/integration/transport/topology/httpsReverseProxy.test.ts": 1696, + "src/server/test/system/serverGatewayCredentialVerification.test.ts": 1637, + "src/test/integration/shutdown/completeShutdownScenario.test.ts": 1601, + "src/server/domains/monitoring/catalogService.test.ts": 1575, + "src/server/database/migrations/migrationLedgerValidation.test.ts": 1565, + "src/server/domains/security/requestAuthenticationAutomation.test.ts": 1484, + "src/server/domains/security/automation/lifecycleRepository.test.ts": 1453, + "src/server/domains/security/authenticationLifecycle.sessions.test.ts": 1433, + "src/server/domains/cache/repository.test.ts": 1387, + "src/server/domains/security/mfa/loginLifecycle.totp.test.ts": 1363, + "src/server/platform/runtime/applicationRuntime.test.ts": 1356, + "scripts/delivery/databaseTransitionFilesystem.test.ts": 1322, + "src/server/test/system/serverAutomationSecurityLostResponse.test.ts": 1265, + "src/server/domains/security/automation/lifecycleCredential.test.ts": 1209, + "src/app/dashboardServer.test.ts": 1197, + "src/server/platform/gateway/persistentGatewayTransport.test.ts": 1188, + "src/test/integration/websocket/nativeWebSocketTransport.test.ts": 1117, + "src/server/platform/runtime/dashboardApplicationRuntime.test.ts": 1105, + "src/server/database/migrations/migrationGraph.test.ts": 1098, + "src/server/domains/tasks/service.test.ts": 1085, + "src/server/database/runtime/databaseSnapshot.test.ts": 953, + "src/server/platform/realtime/eventPumpSubscriptionBackpressure.test.ts": 936, + "src/server/database/migrations/auditEventsSchema.test.ts": 895, + "src/server/domains/security/securityAuditLifecycle.test.ts": 885, + "src/server/domains/security/mfa/accountLifecycle.proofs.test.ts": 860, + "src/server/domains/security/authenticationLifecycle.password.test.ts": 848, + "src/server/test/system/serverFoundation.test.ts": 847, + "src/server/domains/security/password.test.ts": 845, + "src/test/integration/outbox/sqliteOutboxScenario.test.ts": 820, + "src/worker/logs/managedLogRotation.test.ts": 813, + "src/server/platform/realtime/eventPumpSubscriptionReplay.test.ts": 811, + "src/server/platform/realtime/eventPumpSubscriptionCancellation.test.ts": 785, + "src/server/test/system/serverRealtime.test.ts": 755, + "src/server/domains/chat/reducer.test.ts": 743, + "src/server/platform/logs/safeLogReader.test.ts": 740, + "src/server/domains/agents/service.test.ts": 732, + "src/server/domains/monitoring/procedures.test.ts": 695, + "src/server/database/migrations/authenticationRateLimitSchema.test.ts": 663, + "src/server/database/migrations/monitoringSchema.test.ts": 645, + "src/server/domains/security/requestAuthenticationSession.test.ts": 644, + "src/server/domains/jobs/workerSystem.test.ts": 602, + "src/server/domains/security/mfa/accountLifecycle.factors.test.ts": 599, + "src/server/platform/gateway/persistentGatewayProtocol.test.ts": 583, + "src/server/domains/security/authenticationWorkGate.webAuthn.test.ts": 556, + "src/server/domains/monitoring/serviceBoundary.test.ts": 523, + "src/server/platform/realtime/eventStore.test.ts": 520, + "src/server/domains/monitoring/serviceLifecycle.test.ts": 503, + "src/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts": 503, + "src/server/domains/cache/service.test.ts": 487, + "src/server/domains/security/requestAuthenticationRepository.test.ts": 483, + "src/server/domains/agents/procedures.test.ts": 458, + "src/server/platform/errors/safeFailure.test.ts": 447, + "src/server/domains/openClawTasks/realtime.test.ts": 434, + "src/server/domains/security/mfa/lifecycleRepository.test.ts": 433, + "src/server/domains/tasks/procedures.test.ts": 430, + "src/server/domains/security/authenticationLifecycle.login.test.ts": 420, + "src/server/test/system/serverAuthenticationTransport.test.ts": 420, + "scripts/delivery/buildProcesses.test.ts": 418, + "src/server/database/migrations/webauthnLifecycleSchema.test.ts": 413, + "src/server/domains/monitoring/serviceOrdering.test.ts": 400, + "src/server/domains/security/mfa/loginLifecycle.recovery.test.ts": 398, + "src/server/platform/gateway/persistentGatewayOpenClawTasksProvider.test.ts": 398, + "src/server/platform/realtime/eventPumpPolling.test.ts": 387, + "src/server/domains/openClawCron/operationAudit.test.ts": 385, + "src/server/database/migrations/securityIdentitySchema.browser.test.ts": 383, + "src/server/domains/jobs/coordinator.test.ts": 383, + "src/test/integration/shutdown/shutdownServiceResources.test.ts": 380, + "src/server/platform/files/descriptorWorkspaceFileReader.test.ts": 375, + "src/server/database/migrations/taskSchema.test.ts": 374, + "src/server/test/contracts/trpcErrors.test.ts": 364, + "src/server/domains/openClawCron/procedures.test.ts": 358, + "src/server/domains/system/procedures.test.ts": 353, + "src/server/trpc/trpc.test.ts": 352, + "src/server/domains/files/service.test.ts": 344, + "src/server/test/system/serverShutdown.test.ts": 343, + "scripts/delivery/installLogMaintenanceProvisioning.test.ts": 340, + "src/server/platform/gateway/persistentGatewaySessionsProvider.test.ts": 339, + "src/server/domains/security/mfa/loginLifecycle.pending.test.ts": 337, + "src/server/domains/security/mfa/accountLifecycle.maintenance.test.ts": 331, + "src/server/platform/realtime/eventPumpServiceSubscription.test.ts": 331, + "src/server/database/migrations/mfaLifecycleSchema.test.ts": 323, + "src/server/database/migrations/securityIdentitySchema.baseline.test.ts": 322, + "src/server/domains/security/mfa/procedures.test.ts": 316, + "src/server/domains/security/authPendingMfaRoutes.webAuthn.test.ts": 314, + "src/server/domains/openClawCron/sqliteIntentStore.test.ts": 313, + "src/worker/logs/logMaintenanceAvailabilityPublisher.test.ts": 306, + "src/server/rawHttp/chatMedia.test.ts": 300, + "src/server/trpc/appRouterOptionalFeatureServices.test.ts": 300, + "src/server/domains/security/authenticationLifecycleRepository.test.ts": 296, + "src/server/domains/tasks/taskNotificationQueue.test.ts": 294, + "src/app/dashboardServerProcess.test.ts": 292, + "src/server/rawHttp/chatSpeech.test.ts": 286, + "src/server/platform/files/descriptorWorkspaceFileUploadSpool.test.ts": 285, + "src/server/trpc/procedureErrorPolicy.test.ts": 285, + "src/server/domains/jobs/workerRuntime.test.ts": 283, + "src/server/domains/files/procedures.test.ts": 282, + "scripts/sourceBoundaries/checkerIntegration.test.ts": 280, + "scripts/sourceBoundaries/sourceDiscovery.test.ts": 275, + "src/server/platform/logs/sourceCatalog.test.ts": 274, + "scripts/delivery/productionActivationJournal.test.ts": 273, + "src/server/database/migrations/securityIdentitySchema.automation.test.ts": 272, + "src/server/domains/gatewaySessions/controlAuditStore.test.ts": 272, + "scripts/delivery/deploymentLease.test.ts": 270, + "src/server/platform/realtime/eventPumpServiceFairness.test.ts": 270, + "src/server/database/migrations/migrationApplicationTime.test.ts": 269, + "src/server/test/system/serverAuthenticationResponses.test.ts": 268, + "src/server/domains/security/automation/procedures.test.ts": 267, + "src/server/platform/realtime/eventPumpSubscriptionRetention.test.ts": 266, + "src/server/database/migrations/realtimeSchema.test.ts": 263, + "src/server/domains/jobs/scheduleTime.test.ts": 263, + "src/test/integration/openclaw/sourceAudit.test.ts": 261, + "src/server/database/migrations/loadVerifiedMigrations.test.ts": 258, + "scripts/sourceBoundaries/boundaryConfiguration.test.ts": 257, + "src/server/domains/security/authenticationWorkGate.test.ts": 256, + "src/server/domains/security/securityAuditProcedures.test.ts": 256, + "src/server/domains/openClawCron/service.test.ts": 255, + "src/server/database/migrations/agentTaskRunsSchema.test.ts": 253, + "src/server/domains/gatewayConnection/procedures.test.ts": 249, + "src/server/domains/jobs/procedures.test.ts": 246, + "src/test/support/asyncCleanupStack.test.ts": 245, + "src/app/trpcHttpHandler.test.ts": 242, + "scripts/sourceBoundaries/importTargetValidation.test.ts": 237, + "src/test/integration/resources/runSseMemoryEvidence.test.ts": 231, + "src/server/test/contracts/superjsonTransport.test.ts": 229, + "src/server/domains/security/procedures.test.ts": 227, + "src/server/platform/gateway/persistentGatewayRealtimeBridge.test.ts": 224, + "src/server/database/migrations/jsonObjectConstraints.test.ts": 219, + "src/server/domains/tasks/repositoryReader.test.ts": 210, + "src/server/domains/logs/operationAudit.test.ts": 209, + "scripts/sourceBoundaries/importGraph.test.ts": 208, + "src/server/platform/realtime/renewableStreamLease.test.ts": 208, + "src/server/domains/monitoring/testSupport/services.test.ts": 205, + "src/server/domains/security/mfa/webauthn/adapter.test.ts": 203, + "src/server/trpc/context.test.ts": 203, + "src/server/domains/realtime/procedures.test.ts": 202, + "src/server/trpc/appRouter.test.ts": 202, + "src/server/domains/chat/procedures.test.ts": 198, + "src/server/platform/chat/chatSpeechRecording.test.ts": 198, + "src/server/domains/cache/procedures.test.ts": 195, + "src/server/domains/files/rawHttp.test.ts": 194, + "src/server/platform/gateway/persistentGatewayChatProvider.test.ts": 194, + "src/worker/taskNotifications.test.ts": 191, + "src/server/domains/gatewaySessions/service.test.ts": 189, + "src/server/domains/jobs/actionExecutors.test.ts": 189, + "scripts/runTestSuite.test.ts": 187, + "src/app/worker.test.ts": 187, + "src/server/domains/files/jobScheduler.test.ts": 181, + "src/server/platform/gateway/gatewayCredentialVerifier.test.ts": 178, + "src/server/domains/realtime/transport.test.ts": 176, + "src/server/database/validation/rowSchemaIntegration.test.ts": 174, + "src/server/platform/realtime/eventPumpServicePolling.test.ts": 173, + "src/server/platform/chat/inMemoryChatAttachmentStore.test.ts": 172, + "src/server/domains/realtime/errors.test.ts": 168, + "scripts/delivery/productionRuntime.test.ts": 162, + "src/server/domains/jobs/logMaintenanceQueue.test.ts": 161, + "src/server/domains/monitoring/normalization.test.ts": 159, + "src/server/domains/gatewaySessions/procedures.test.ts": 158, + "src/test/integration/resourceBudgets/resourceBudgetOrchestration.test.ts": 154, + "src/server/platform/gateway/persistentOpenClawCronProvider.test.ts": 149, + "scripts/delivery/productionActivationState.test.ts": 148, + "src/server/database/validation/userRecoveryCodes.test.ts": 139, + "src/server/platform/configuration/workerConfiguration.test.ts": 139, + "src/app/databaseMaintenance.test.ts": 132, + "src/test/parity/parityInventory.test.ts": 130, + "scripts/delivery/productionDeliveryFilesystem.test.ts": 129, + "src/server/platform/realtime/eventPumpServiceLifecycle.test.ts": 127, + "src/server/platform/configuration/webConfiguration.test.ts": 125, + "src/test/integration/files/boundedFile.test.ts": 124, + "src/server/platform/chat/elevenLabsSpeechProvider.test.ts": 118, + "src/server/domains/realtime/authenticationLeaseStream.test.ts": 117, + "scripts/documentation/artifacts.test.ts": 115, + "scripts/delivery/buildAdmission.test.ts": 111, + "src/worker/files/workspaceFileReplaceIntent.test.ts": 110, + "src/app/trpcRequestPolicy.test.ts": 109, + "src/server/database/validation/auditEvents.test.ts": 107, + "src/server/platform/observability/effectLogger.test.ts": 107, + "src/server/platform/terminal/rootRegistry.test.ts": 106, + "src/server/database/runtime/databasePath.test.ts": 105, + "scripts/delivery/productionStateFilesystem.test.ts": 104, + "src/contracts/cache.test.ts": 97, + "src/server/domains/jobs/actionRegistry.test.ts": 97, + "src/server/platform/logs/redaction.test.ts": 93, + "scripts/buildSourceIdentity.test.ts": 92, + "scripts/delivery/activateProductionRelease.test.ts": 92, + "src/contracts/jobs.test.ts": 91, + "src/server/domains/jobs/sideEffects.test.ts": 91, + "src/server/database/validation/rowSchemas.test.ts": 90, + "src/server/domains/logs/service.test.ts": 90, + "src/server/platform/chat/inMemoryChatMediaReferences.test.ts": 90, + "src/contracts/monitoring.test.ts": 87, + "src/server/database/validation/automationCredentials.test.ts": 87, + "src/server/rawHttp/frontendAssets.test.ts": 87, + "src/server/rawHttp/terminalSocket.test.ts": 86, + "src/server/database/schema/webauthnPersistence.test.ts": 80, + "src/server/database/validation/authSessions.test.ts": 77, + "src/server/platform/logs/logMaintenanceAvailability.test.ts": 77, + "scripts/delivery/releaseArtifactInventory.test.ts": 76, + "src/server/database/validation/taskRows.test.ts": 75, + "src/server/domains/chat/history.test.ts": 75, + "src/contracts/automationSecurity.test.ts": 74, + "src/contracts/schedules.test.ts": 72, + "scripts/documentation/jsonSchema.test.ts": 71, + "src/contracts/gatewaySessions.test.ts": 71, + "src/server/platform/realtime/eventPumpContract.test.ts": 71, + "src/contracts/contractRegistry.test.ts": 69, + "src/server/domains/openClawTasks/service.test.ts": 68, + "src/server/platform/observability/projectFileLogSink.test.ts": 68, + "src/server/platform/files/openClawFileRootConfiguration.test.ts": 66, + "src/contracts/reports.test.ts": 65, + "src/server/database/validation/incidentObservations.test.ts": 65, + "src/contracts/jobModel.test.ts": 64, + "src/worker/terminal/workerTerminalBrokerLifecycle.test.ts": 64, + "src/server/database/validation/incidents.test.ts": 63, + "src/contracts/chatModel.test.ts": 62, "src/server/domains/terminal/service.test.ts": 62, - "src/server/platform/gateway/chatTranscriptLifecycleSupervisor.test.ts": 62, - "src/worker/terminal/terminalBrokerServer.test.ts": 62, - "src/contracts/chatMedia.test.ts": 61, - "src/contracts/reports.test.ts": 61, - "src/server/database/validation/userRecoveryCodes.test.ts": 61, - "src/server/domains/security/mfa/webauthn/credentialState.test.ts": 61, - "src/contracts/agentModel.test.ts": 60, - "src/contracts/tasks.test.ts": 60, - "src/server/domains/openClawCron/expiryReconciler.test.ts": 60, - "src/server/rawHttp/requestSecurity.test.ts": 60, - "src/server/database/validation/automationPrincipalCapabilities.test.ts": 59, - "src/server/database/validation/incidents.test.ts": 58, - "scripts/delivery/logMaintenanceProvisioningPolicy.test.ts": 57, - "src/server/database/validation/notifications.test.ts": 57, - "src/server/domains/chat/coalescer.test.ts": 56, - "src/server/domains/security/mfa/webauthn/relyingPartyConfiguration.test.ts": 56, - "src/server/database/validation/userWebAuthnCredentials.test.ts": 55, - "src/contracts/openClawTasks.test.ts": 54, - "src/contracts/terminal.test.ts": 53, - "src/contracts/files.test.ts": 52, - "src/test/integration/resources/sseMemoryEvidence.test.ts": 52, - "src/server/domains/security/audit.test.ts": 51, - "src/contracts/logs.test.ts": 50, - "src/server/domains/security/mfa/totp.test.ts": 50, - "src/test/integration/transport/realtime/eventFeed.test.ts": 50, - "src/worker/files/openClawFileRootConfiguration.test.ts": 49, - "scripts/runStorybookTests.test.ts": 48, - "src/contracts/gatewaySessions.test.ts": 48, - "src/server/database/validation/reports.test.ts": 48, - "src/test/integration/resourceBudgets/resourceBudgetPolicy.test.ts": 47, - "src/server/domains/security/authenticationPolicy.test.ts": 46, - "scripts/delivery/prepareProductionState.test.ts": 44, - "src/server/domains/security/authenticationResolution.test.ts": 44, - "src/shared/chatStreamText.test.ts": 44, - "scripts/checkCoverage.test.ts": 43, - "src/server/domains/security/mfa/totpSecretCipher.test.ts": 43, + "src/contracts/openClawCron.test.ts": 61, + "src/worker/logs/logMaintenanceExecutor.test.ts": 61, + "src/worker/terminal/ptyProcess.test.ts": 61, + "src/worker/terminal/terminalSessionBroker.test.ts": 61, + "src/server/database/validation/automationPrincipals.test.ts": 60, + "src/server/database/validation/cacheEntries.test.ts": 60, + "src/contracts/jobRealtime.test.ts": 59, + "src/contracts/events.test.ts": 58, + "src/contracts/logs.test.ts": 58, + "src/server/platform/release/runtimeRelease.test.ts": 58, + "src/test/integration/resources/unitIdentity.test.ts": 58, + "src/contracts/cacheRealtime.test.ts": 57, + "src/server/domains/security/audit.test.ts": 57, + "src/server/database/validation/monitorRuns.test.ts": 56, + "src/server/database/validation/userWebAuthnCredentials.test.ts": 56, + "src/server/rawHttp/pendingLoginCookie.test.ts": 56, + "src/server/database/schema/cacheEntries.test.ts": 55, + "src/server/database/validation/automationPrincipalCapabilities.test.ts": 55, + "src/server/database/validation/realtimeEvents.test.ts": 55, + "src/server/domains/chat/coalescer.test.ts": 55, + "src/contracts/gatewayConnection.test.ts": 54, + "src/server/database/validation/authChallenges.test.ts": 54, + "src/server/domains/agents/gatewayAvailability.test.ts": 54, + "src/server/domains/openClawCron/expiryReconciler.test.ts": 54, + "src/server/database/validation/notifications.test.ts": 53, + "src/server/database/validation/authPendingLogins.test.ts": 52, + "src/server/domains/gatewaySessions/controlAudit.test.ts": 51, + "src/worker/files/workspaceFileRootConfiguration.test.ts": 51, + "scripts/checkCoverage.test.ts": 50, + "src/contracts/accountSecurity.test.ts": 50, + "src/server/domains/security/authenticationSession.test.ts": 50, + "src/server/domains/security/mfa/webauthn/credentialState.test.ts": 50, + "src/server/platform/observability/structuredLogger.test.ts": 50, + "src/server/platform/terminal/terminalBrokerClient.test.ts": 50, + "scripts/runCoverage.test.ts": 49, + "scripts/runStorybookTests.test.ts": 49, + "src/server/platform/logs/logRotationEpochProbe.test.ts": 49, + "src/server/domains/cache/systemHostProvider.test.ts": 48, + "src/test/integration/resources/resourcePolicy.test.ts": 47, + "src/worker/terminal/terminalBrokerServer.test.ts": 47, + "src/contracts/chatMedia.test.ts": 46, + "src/server/database/validation/reports.test.ts": 46, + "src/server/domains/security/recentAuthentication.test.ts": 46, + "src/test/integration/resources/sseMemoryEvidence.test.ts": 46, + "src/worker/files/openClawFileRootConfiguration.test.ts": 45, + "src/contracts/system.test.ts": 44, + "src/server/database/validation/users.test.ts": 43, + "src/server/domains/security/authenticationResolution.test.ts": 43, "src/server/rawHttp/authenticationClientSource.test.ts": 43, - "scripts/runCoverage.test.ts": 42, - "src/server/platform/gateway/gatewayCredentialProtocol.test.ts": 42, - "src/server/platform/realtime/boundedAsyncQueue.test.ts": 42, - "src/server/platform/terminal/terminalBrokerClient.test.ts": 42, - "scripts/delivery/systemctlProcess.test.ts": 41, - "src/server/database/schema/automationPersistence.test.ts": 41, - "src/server/domains/gatewayConnection/service.test.ts": 40, - "src/contracts/monitoring.test.ts": 39, - "src/server/platform/files/workspaceFileRootConfiguration.test.ts": 38, - "src/shared/releaseManifest.test.ts": 38, - "src/shared/validation.test.ts": 38, - "src/test/integration/resources/systemdLauncher.test.ts": 38, - "scripts/documentation/routesMarkdown.test.ts": 37, - "src/contracts/taskModel.test.ts": 36, - "src/server/rawHttp/authenticationCredentials.test.ts": 36, - "src/server/rawHttp/sessionCookie.test.ts": 36, - "src/shared/configRedaction.test.ts": 36, - "src/server/domains/system/systemMetricsService.test.ts": 35, - "src/contracts/notifications.test.ts": 34, - "src/server/platform/gateway/openClawTasksSubscriptionSupervisor.test.ts": 34, - "src/test/integration/resources/cgroupV2.test.ts": 34, - "src/test/integration/transport/topology/proxyTransport.test.ts": 32, - "src/contracts/gatewayConnection.test.ts": 31, - "scripts/checkDatabaseSchema.test.ts": 30, - "src/server/domains/security/mfa/recoveryCodes.test.ts": 30, + "src/contracts/securityAudit.test.ts": 41, + "src/server/database/validation/authRateLimitBuckets.test.ts": 41, + "src/server/rawHttp/requestSecurity.test.ts": 40, + "src/contracts/agentModel.test.ts": 39, + "src/server/database/validation/userTotpFactors.test.ts": 39, + "src/shared/releaseManifest.test.ts": 39, + "src/contracts/agents.test.ts": 38, + "src/server/domains/security/mfa/webauthn/relyingPartyConfiguration.test.ts": 38, + "src/test/setup.test.ts": 38, + "src/contracts/notifications.test.ts": 37, + "src/contracts/files.test.ts": 36, + "src/contracts/tasks.test.ts": 36, + "src/server/domains/security/mfa/totp.test.ts": 36, + "src/contracts/auth.test.ts": 35, + "src/contracts/taskModel.test.ts": 35, + "src/contracts/openClawTasks.test.ts": 34, + "src/server/rawHttp/authenticationCredentials.test.ts": 34, + "scripts/delivery/prepareProductionState.test.ts": 33, + "scripts/delivery/systemctlProcess.test.ts": 33, + "src/shared/configRedaction.test.ts": 31, + "src/server/database/migrations/verifyDatabaseIntegrity.test.ts": 30, + "src/contracts/terminal.test.ts": 29, + "src/server/domains/gatewayConnection/service.test.ts": 29, "src/server/domains/security/authenticationWorkBudget.test.ts": 29, + "src/server/domains/tasks/taskNotification.test.ts": 29, "src/server/platform/filesystem/projectLayout.test.ts": 29, - "src/server/shared/crypto.test.ts": 29, - "scripts/documentation/configurationMarkdown.test.ts": 28, - "scripts/packageIdentity.test.ts": 28, - "src/contracts/security.test.ts": 28, - "src/contracts/webauthn.test.ts": 28, - "src/test/integration/runtime/runtimeCandidate.test.ts": 27, - "src/server/domains/chat/subscriptionManager.test.ts": 26, - "src/shared/json.test.ts": 26, - "src/contracts/system.test.ts": 25, - "src/server/database/migrations/verifyDatabaseIntegrity.test.ts": 25, - "src/server/domains/system/systemMetricsCollector.test.ts": 25, + "src/server/domains/security/mfa/totpSecretCipher.test.ts": 28, + "src/server/platform/gateway/chatTranscriptLifecycleSupervisor.test.ts": 28, + "scripts/packageIdentity.test.ts": 27, + "src/contracts/webauthn.test.ts": 27, + "src/server/database/schema/automationPersistence.test.ts": 27, + "src/test/integration/resources/pausedTlsSseHandshake.test.ts": 27, + "src/test/integration/transport/realtime/eventFeed.test.ts": 27, + "scripts/checkDatabaseSchema.test.ts": 26, + "src/server/rawHttp/sessionCookie.test.ts": 26, + "src/server/domains/system/systemMetricsService.test.ts": 25, + "src/server/platform/configuration/configurationRegistry.test.ts": 25, + "src/server/platform/runtime/readRuntimeIdentity.test.ts": 25, + "src/test/integration/resources/cgroupV2.test.ts": 25, "src/test/integration/resources/cgroupV2Hierarchy.test.ts": 25, - "src/server/shared/opaqueToken.test.ts": 24, - "src/test/integration/resources/pausedTlsSseHandshake.test.ts": 24, - "src/worker/logs/fixedSystemLogrotateBroker.test.ts": 24, - "src/shared/encoding.test.ts": 23, - "src/worker/terminal/bunTerminalBrokerIpc.test.ts": 23, + "src/worker/terminal/bunTerminalBrokerIpc.test.ts": 25, + "src/server/platform/gateway/openClawTasksSubscriptionSupervisor.test.ts": 24, + "src/shared/dateTime.test.ts": 24, + "src/shared/json.test.ts": 24, + "src/test/integration/runtime/runtimeCandidate.test.ts": 24, + "src/test/integration/transport/topology/proxyTransport.test.ts": 24, + "scripts/delivery/logMaintenanceProvisioningPolicy.test.ts": 23, + "scripts/documentation/routesMarkdown.test.ts": 23, + "src/server/domains/security/authenticationPolicy.test.ts": 23, "scripts/testOutputPolicy.test.ts": 22, - "src/test/integration/resources/processMemory.test.ts": 21, - "src/server/platform/runtime/readRuntimeIdentity.test.ts": 20, - "src/server/platform/terminal/bunUnixTerminalBrokerTransport.test.ts": 19, - "src/app/dashboardChatRuntimeMaintenance.test.ts": 17, - "src/server/platform/configuration/configurationRegistry.test.ts": 17, - "src/server/platform/runtime/processSignals.test.ts": 15, - "src/worker/logs/systemLogrotateProvisioning.test.ts": 15 - } + "src/server/domains/system/systemMetricsCollector.test.ts": 22, + "src/test/integration/resources/systemdLauncher.test.ts": 22, + "src/worker/logs/fixedSystemLogrotateBroker.test.ts": 22, + "src/server/platform/gateway/gatewayCredentialProtocol.test.ts": 21, + "src/server/shared/opaqueToken.test.ts": 21, + "scripts/documentation/configurationMarkdown.test.ts": 20, + "src/server/domains/security/mfa/recoveryCodes.test.ts": 20, + "src/server/platform/files/workspaceFileRootConfiguration.test.ts": 20, + "src/server/platform/terminal/bunUnixTerminalBrokerTransport.test.ts": 20, + "src/test/integration/resourceBudgets/resourceBudgetPolicy.test.ts": 20, + "src/test/integration/resources/processMemory.test.ts": 20, + "src/contracts/chatSpeech.test.ts": 19, + "src/contracts/security.test.ts": 19, + "src/server/domains/chat/subscriptionManager.test.ts": 19, + "src/server/platform/realtime/boundedAsyncQueue.test.ts": 19, + "src/shared/validation.test.ts": 19, + "src/server/shared/crypto.test.ts": 18, + "src/shared/chatStreamText.test.ts": 18, + "src/worker/logs/systemLogrotateProvisioning.test.ts": 17, + "src/server/platform/runtime/processSignals.test.ts": 16, + "src/shared/encoding.test.ts": 16, + "src/app/dashboardChatRuntimeMaintenance.test.ts": 15 + }, + "version": 1 } diff --git a/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md b/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md index b74f1aa93..9ac5ff0c0 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md +++ b/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md @@ -201,6 +201,8 @@ queryable lifecycle. | Incident notification | unique `(incident_id, incident_generation, channel)` when incident is non-null | | Queue claim | partial `job_runs(available_at, priority DESC, queued_at, id) WHERE state = 'queued'` | | One active scheduled run | unique partial `job_runs(scheduled_job_id) WHERE state IN ('queued', 'running')` | +| Active action status | partial `job_runs_action_active_idx`; exact predicate below | +| Terminal maintenance status | partial `job_runs_action_payload_terminal_idx`; exact predicate below | | Worker expiry | `worker_instances(heartbeat_at, id)` | | Job timeline | `job_run_events(job_run_id, sequence)` | | Realtime catch-up | `realtime_events(topic, id)` | @@ -210,6 +212,16 @@ queryable lifecycle. | Cache refresh/expiry | `cache_entries(last_attempt_status, expires_at_ms, key)` | | Audit cursor | `audit_events(occurred_at_ms DESC, id DESC)` plus request/target indexes | +The action-status indexes intentionally mirror the repository's literal predicates: + +- `job_runs_action_active_idx` indexes `(action_key, state DESC, queued_at DESC, id DESC)` + where `state IN ('queued', 'running')`. Maintenance payload equality is filtered after + this globally single-flight action lookup. +- `job_runs_action_payload_terminal_idx` indexes + `(action_key, payload_json, queued_at DESC, id DESC)` where + `action_key = 'maintenance.rotate-logs'`, `length(CAST(payload_json AS BLOB)) <= 128`, + and `state IN ('cancelled', 'failed', 'succeeded', 'timed-out')`. + Primary keys and unique constraints already create indexes; the schema does not add redundant copies. Partial-index predicates must match query predicates exactly enough for SQLite to use them. diff --git a/greenfield/docs/architecture/greenfield-rewrite/progress.md b/greenfield/docs/architecture/greenfield-rewrite/progress.md index 03d7538ac..c450ce905 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/progress.md +++ b/greenfield/docs/architecture/greenfield-rewrite/progress.md @@ -14,7 +14,7 @@ closes a phase; dated entries below provide the evidence, not a second status so | 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. | | 3 — Core operator domains | Started | Task and agent-directory parity are implemented with durable history, realtime invalidation, and browser workflows. Monitoring ingestion plus report, incident, and notification server/browser parity are implemented. Dashboard-local durable schedules/jobs, real worker execution, their `/jobs` operator UI, the first claim-fenced `system.host` cache provider, its cache browser, and bounded system metrics are implemented. Root composition covers every implemented Phase 3 operator domain, and Phase 4A now supplies the OpenClaw-cron half of `/jobs`. Full root parity and privileged/external providers remain later gates, so the Phase 3 exit stays open. | | 4 — Gateway and chat | Started | The current installed OpenClaw source is hash-pinned for the persistent sessions, cron, chat, companion, task, and media surfaces. Process-owned Gateway lifecycle, durable realtime invalidation, sessions and agent availability, OpenClaw cron/tasks, the compact heartbeat, the durable chat journal/runtime, bounded history and reconciliation, attachments/media proxy, and the `/chat` frontend are implemented. Recorded contract, protocol, service, browser, restart, load-boundary, and security tests cover the slice; live Gateway smoke/restart evidence and the Phase 4 exit gate remain open. | -| 5 — Privileged and external domains | Started | Files now has named-root browsing, ticketed raw transfer, bounded spooling, CAS writes, worker execution, and browser workflows. Logs has redacted named-source reads plus separate custom app/container rotation and fixed host-logrotate policies. Terminal is a worker-owned interactive PTY over a hardened WebSocket with bounded reconnect replay. Docker control, database, Moltbook, settings, GitHub, deployment, backup, and the remaining privileged adapters stay open; the Phase 5 exit gate is not claimed. | +| 5 — Privileged and external domains | Started | Files now has named-root browsing, ticketed raw transfer, bounded spooling, CAS writes, worker execution, and browser workflows. Logs has redacted named-source reads, durable active/latest-terminal maintenance status, worker-owned managed dry runs, separate custom app/container rotation, fixed host-logrotate policies, and closed reviewed `/logs` parity. Terminal is a worker-owned interactive PTY over a hardened WebSocket with bounded reconnect replay. Docker control, database, Moltbook, settings, GitHub, deployment, backup, full Files parity, and the remaining privileged adapters stay open; the Phase 5 exit gate is not claimed. | | 6 — Parity, hardening, and cutover | Not started | Full UI parity, generated `/docs`, load/resource/restore evidence, cutover rehearsal, fresh production database, and legacy removal remain open. | ### 2026-08-03 — Phase 0 started @@ -1274,9 +1274,10 @@ full-browser parity, production rehearsal, cutover, and legacy deletion remain o - Logs exposes a path-free catalog for exact Dashboard files, exact host text logs, and bounded dated OpenClaw files. Descriptor reads enforce owner, mode, link, type, size, and partial-read checks before centralized secret redaction; stable line identities derive only from redacted - text. Tail and search remain bounded and browser-session-only. The `/logs` route retains validated - data through safe failures and queues only fixed reviewed maintenance policy IDs after recent - MFA, durable audit, and job admission. The worker periodically probes its managed rotation engine + text. Tail and search remain bounded and browser-session-only. The `/logs` route hides cached + snapshots after source-availability or read-refresh failures rather than rendering or exporting + stale lines, and queues only fixed reviewed maintenance policy IDs after recent MFA, durable + audit, and job admission. The worker periodically probes its managed rotation engine and only `LoadState` for the four fixed host units, then atomically publishes a private, contract-ordered policy-ID projection under project-local log-maintenance state. The web process has no process or rotation-state authority: it reads only that bounded `0600` projection and @@ -1309,3 +1310,28 @@ full-browser parity, production rehearsal, cutover, and legacy deletion remain o reviewed PTY replacement as implemented; `/files`, `/logs`, and legacy rotation-status parity remain planned until their outstanding full-parity behavior and real runtime status exist. The other Phase 5 domains and the aggregate privileged-operation exit gate remain open. + +### 2026-08-10 — Phase 5 Logs parity closed + +- Maintenance status now reads the five exact real-run payloads plus managed dry-run activity + through one bounded repository snapshot. Each policy exposes a running-preferred active run + separately from its latest terminal real run, while successful managed results project only + bounded path-free aggregate counts. Dry-run jobs remain independently observable and do not + replace the policy's real-run history. +- `docker-managed` dry runs use the same durable recent-MFA, audit, admission, worker execution, + and settlement path as real maintenance. Host dry runs fail closed before invoking the fixed + system broker. Unknown worker result content never crosses the contract boundary. +- The `/logs` browser keeps maintenance controls available even with no configured read sources, + locks all policies while the shared `host.logs` resource is active, follows the requested run + through realtime invalidation with a bounded polling fallback, and renders only the validated + terminal summary. Failed detail reads recover without leaving the controls permanently locked. +- Credential redaction consumes complete scalar, structured, malformed, authorization, and cookie + values before search matching. Search inspects at most 4,000 newest physical lines per request, + and line identities use framed, redacted generation tuples. Managed copy-truncate publishes a + source-specific `rotating` marker before truncation and commits the pending epoch only after file + sync; a fresh worker instance safely completes interrupted pre-truncate, empty, and below-threshold + regrowth cases while web reads remain fail-closed. Queued maintenance audit rows retain only the + public policy target, request identity, and classified settlement metadata. +- The reviewed `/logs` route plus legacy maintenance status, managed dry-run, and real-run + operations are now recorded as implemented. `/files`, Docker control, database, Moltbook, + settings, GitHub, delivery, backup, and the aggregate Phase 5 exit gate remain open. diff --git a/greenfield/docs/generated/procedures.md b/greenfield/docs/generated/procedures.md index f4f7d21d1..6936d1bd1 100644 --- a/greenfield/docs/generated/procedures.md +++ b/greenfield/docs/generated/procedures.md @@ -80,7 +80,7 @@ | `jobs.listRuns` | query | jobs | Authenticated: jobs:read | [input](./schemas/jobs.listRuns.input.schema.json) | [output](./schemas/jobs.listRuns.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Lists stable newest-first durable run history and queue state. | | `jobs.setClaimingPaused` | mutation | jobs | Authenticated browser session: jobs:write | [input](./schemas/jobs.setClaimingPaused.input.schema.json) | [output](./schemas/jobs.setClaimingPaused.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Pauses or resumes new cross-process claims under version control. | | `logs.listSources` | query | logs | Authenticated browser session: logs:read | [input](./schemas/logs.listSources.input.schema.json) | [output](./schemas/logs.listSources.output.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Lists the bounded named log-source catalog without filesystem paths. | -| `logs.maintenanceStatus` | query | logs | Authenticated browser session: logs:read | [input](./schemas/logs.maintenanceStatus.input.schema.json) | [output](./schemas/logs.maintenanceStatus.output.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Reports which reviewed fixed log-maintenance policies can be queued. | +| `logs.maintenanceStatus` | query | logs | Authenticated browser session: logs:read | [input](./schemas/logs.maintenanceStatus.input.schema.json) | [output](./schemas/logs.maintenanceStatus.output.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Reports reviewed policy queueability plus active and latest terminal maintenance runs. | | `logs.requestMaintenance` | mutation | logs | Authenticated browser session: logs:write; MFA enrollment required; recent MFA when enabled | [input](./schemas/logs.requestMaintenance.input.schema.json) | [output](./schemas/logs.requestMaintenance.output.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Queues one audited worker-owned invocation of an exact reviewed log policy. | | `logs.search` | query | logs | Authenticated browser session: logs:read | [input](./schemas/logs.search.input.schema.json) | [output](./schemas/logs.search.output.schema.json) | `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Searches only a bounded redacted tail window of one named source. | | `logs.tail` | query | logs | Authenticated browser session: logs:read | [input](./schemas/logs.tail.input.schema.json) | [output](./schemas/logs.tail.output.schema.json) | `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Reads one redacted bounded tail from an exact named source. | diff --git a/greenfield/docs/generated/schemas/chat.runtime.output.schema.json b/greenfield/docs/generated/schemas/chat.runtime.output.schema.json index a807b213d..ffef66cfe 100644 --- a/greenfield/docs/generated/schemas/chat.runtime.output.schema.json +++ b/greenfield/docs/generated/schemas/chat.runtime.output.schema.json @@ -290,6 +290,11 @@ "kind": { "const": "item" }, + "occurredAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, "sequence": { "type": "integer", "minimum": 1, @@ -1510,6 +1515,11 @@ "kind": { "const": "item" }, + "occurredAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, "sequence": { "type": "integer", "minimum": 1, diff --git a/greenfield/docs/generated/schemas/logs.maintenanceStatus.output.schema.json b/greenfield/docs/generated/schemas/logs.maintenanceStatus.output.schema.json index 8ef15b808..4efb8e091 100644 --- a/greenfield/docs/generated/schemas/logs.maintenanceStatus.output.schema.json +++ b/greenfield/docs/generated/schemas/logs.maintenanceStatus.output.schema.json @@ -12,6 +12,213 @@ "items": { "type": "object", "properties": { + "activeRun": { + "type": "object", + "properties": { + "actionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "attemptCount": { + "type": "integer", + "minimum": 0, + "maximum": 10 + }, + "attemptLimit": { + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "availableAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "cancellationPolicy": { + "enum": [ + "cooperative", + "never", + "queued-only" + ], + "type": "string" + }, + "cancelRequestedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "displayName": { + "type": "string", + "maxLength": 160, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "eventCount": { + "type": "integer", + "minimum": 0, + "maximum": 1000 + }, + "finishedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "firstStartedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "lastAttemptStartedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "priority": { + "type": "integer", + "minimum": -100, + "maximum": 100 + }, + "queuedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "resourceClass": { + "enum": [ + "exclusive", + "host-heavy", + "interactive", + "light", + "network" + ], + "type": "string" + }, + "resourceKeys": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "maxItems": 32, + "$comment": "Live Valibot validation additionally requires resource keys to be unique, strictly sorted, and within their aggregate UTF-8 byte budget." + }, + "retrySafe": { + "type": "boolean" + }, + "scheduledForAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "scheduledJobId": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "scheduledJobVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "state": { + "enum": [ + "cancelled", + "failed", + "queued", + "running", + "succeeded", + "timed-out" + ], + "type": "string" + }, + "stateVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "terminalCode": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9][a-z0-9._/-]*$" + }, + "terminalMessage": { + "type": "string", + "maxLength": 2000, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "timeoutMs": { + "type": "integer", + "minimum": 1000, + "maximum": 86400000 + }, + "triggerType": { + "enum": [ + "manual", + "schedule", + "startup", + "system" + ], + "type": "string" + }, + "updatedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + } + }, + "required": [ + "actionKey", + "attemptCount", + "attemptLimit", + "availableAtMs", + "cancellationPolicy", + "displayName", + "eventCount", + "id", + "priority", + "queuedAtMs", + "resourceClass", + "resourceKeys", + "retrySafe", + "state", + "stateVersion", + "timeoutMs", + "triggerType", + "updatedAtMs" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires run provenance, attempts, state, cancellation, and timestamps to agree. Live Valibot validation additionally requires an active maintenance run to be queued or running." + }, "id": { "enum": [ "docker-managed", @@ -36,6 +243,303 @@ } ] }, + "lastRun": { + "type": "object", + "properties": { + "run": { + "type": "object", + "properties": { + "actionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "attemptCount": { + "type": "integer", + "minimum": 0, + "maximum": 10 + }, + "attemptLimit": { + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "availableAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "cancellationPolicy": { + "enum": [ + "cooperative", + "never", + "queued-only" + ], + "type": "string" + }, + "cancelRequestedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "displayName": { + "type": "string", + "maxLength": 160, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "eventCount": { + "type": "integer", + "minimum": 0, + "maximum": 1000 + }, + "finishedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "firstStartedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "lastAttemptStartedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "priority": { + "type": "integer", + "minimum": -100, + "maximum": 100 + }, + "queuedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "resourceClass": { + "enum": [ + "exclusive", + "host-heavy", + "interactive", + "light", + "network" + ], + "type": "string" + }, + "resourceKeys": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "maxItems": 32, + "$comment": "Live Valibot validation additionally requires resource keys to be unique, strictly sorted, and within their aggregate UTF-8 byte budget." + }, + "retrySafe": { + "type": "boolean" + }, + "scheduledForAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "scheduledJobId": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "scheduledJobVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "state": { + "enum": [ + "cancelled", + "failed", + "queued", + "running", + "succeeded", + "timed-out" + ], + "type": "string" + }, + "stateVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "terminalCode": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9][a-z0-9._/-]*$" + }, + "terminalMessage": { + "type": "string", + "maxLength": 2000, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "timeoutMs": { + "type": "integer", + "minimum": 1000, + "maximum": 86400000 + }, + "triggerType": { + "enum": [ + "manual", + "schedule", + "startup", + "system" + ], + "type": "string" + }, + "updatedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + } + }, + "required": [ + "actionKey", + "attemptCount", + "attemptLimit", + "availableAtMs", + "cancellationPolicy", + "displayName", + "eventCount", + "id", + "priority", + "queuedAtMs", + "resourceClass", + "resourceKeys", + "retrySafe", + "state", + "stateVersion", + "timeoutMs", + "triggerType", + "updatedAtMs" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires run provenance, attempts, state, cancellation, and timestamps to agree. Live Valibot validation additionally requires a last maintenance run to be terminal." + }, + "summary": { + "type": "object", + "properties": { + "actionCounts": { + "type": "object", + "properties": { + "compressed": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "deleted": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "error": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "missing": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "rotated": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "skipped": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "compressed", + "deleted", + "error", + "missing", + "rotated", + "skipped" + ], + "additionalProperties": false + }, + "checkedTargets": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "dryRun": { + "type": "boolean" + }, + "finishedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "ok": { + "type": "boolean" + }, + "startedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + } + }, + "required": [ + "actionCounts", + "checkedTargets", + "dryRun", + "finishedAtMs", + "ok", + "startedAtMs" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires the maintenance finish timestamp not to precede its start timestamp. Live Valibot validation additionally requires a reported successful maintenance execution to contain no failed actions." + } + }, + "required": [ + "run" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally permits a summary only for a successful non-dry-run maintenance result whose summary reports success." + }, "scope": { "enum": [ "docker", @@ -57,7 +561,8 @@ "scope", "state" ], - "additionalProperties": false + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires each maintenance status to agree with its fixed policy scope, maintenance action identity, and docker-only summary authority." }, "minItems": 5, "maxItems": 5, diff --git a/greenfield/docs/generated/schemas/logs.requestMaintenance.input.schema.json b/greenfield/docs/generated/schemas/logs.requestMaintenance.input.schema.json index b33bcc5f9..ab641a86b 100644 --- a/greenfield/docs/generated/schemas/logs.requestMaintenance.input.schema.json +++ b/greenfield/docs/generated/schemas/logs.requestMaintenance.input.schema.json @@ -2,6 +2,10 @@ "$id": "urn:mira-dashboard:logs.requestMaintenance.input", "type": "object", "properties": { + "dryRun": { + "type": "boolean", + "default": false + }, "idempotencyKey": { "type": "string", "minLength": 32, @@ -24,5 +28,6 @@ "policyId" ], "additionalProperties": false, + "$comment": "Live Valibot validation additionally permits dry-run mode only for the fixed docker-managed policy.", "$schema": "https://json-schema.org/draft/2020-12/schema" } diff --git a/greenfield/docs/generated/schemas/logs.requestMaintenance.output.schema.json b/greenfield/docs/generated/schemas/logs.requestMaintenance.output.schema.json index 0f6e66c51..c7bed45c4 100644 --- a/greenfield/docs/generated/schemas/logs.requestMaintenance.output.schema.json +++ b/greenfield/docs/generated/schemas/logs.requestMaintenance.output.schema.json @@ -2,6 +2,9 @@ "$id": "urn:mira-dashboard:logs.requestMaintenance.output", "type": "object", "properties": { + "dryRun": { + "type": "boolean" + }, "jobRunId": { "type": "string", "minLength": 36, @@ -24,10 +27,12 @@ } }, "required": [ + "dryRun", "jobRunId", "policyId", "queued" ], "additionalProperties": false, + "$comment": "Live Valibot validation additionally permits a queued dry-run result only for the fixed docker-managed policy.", "$schema": "https://json-schema.org/draft/2020-12/schema" } diff --git a/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql b/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql index e2a3a9098..b0748261b 100644 --- a/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql +++ b/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql @@ -1110,6 +1110,8 @@ CREATE INDEX `job_run_events_occurred_run_sequence_idx` ON `job_run_events` (`oc CREATE UNIQUE INDEX `job_runs_idempotency_unique` ON `job_runs` (`requested_by_kind`,`requested_by_id`,`idempotency_key`);--> statement-breakpoint CREATE INDEX `job_runs_claim_idx` ON `job_runs` ("available_at" asc,"priority" desc,"queued_at" asc,"id" asc) WHERE "job_runs"."state" = 'queued';--> statement-breakpoint CREATE UNIQUE INDEX `job_runs_one_active_schedule_idx` ON `job_runs` (`scheduled_job_id`) WHERE "job_runs"."scheduled_job_id" IS NOT NULL AND "job_runs"."state" IN ('queued', 'running');--> statement-breakpoint +CREATE INDEX `job_runs_action_active_idx` ON `job_runs` (`action_key`,"state" desc,"queued_at" desc,"id" desc) WHERE "job_runs"."state" IN ('queued', 'running');--> statement-breakpoint +CREATE INDEX `job_runs_action_payload_terminal_idx` ON `job_runs` (`action_key`,`payload_json`,"queued_at" desc,"id" desc) WHERE "job_runs"."action_key" = 'maintenance.rotate-logs' AND length(CAST("job_runs"."payload_json" AS BLOB)) <= 128 AND "job_runs"."state" IN ('cancelled', 'failed', 'succeeded', 'timed-out');--> statement-breakpoint CREATE INDEX `job_runs_queued_id_idx` ON `job_runs` (`queued_at`,`id`);--> statement-breakpoint CREATE INDEX `job_runs_schedule_queued_id_idx` ON `job_runs` (`scheduled_job_id`,`queued_at`,`id`);--> statement-breakpoint CREATE INDEX `job_runs_running_lease_idx` ON `job_runs` (`lease_expires_at`,`id`) WHERE "job_runs"."state" = 'running';--> statement-breakpoint diff --git a/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json b/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json index f01748ad6..d6c39e8f7 100644 --- a/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json +++ b/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json @@ -6030,6 +6030,58 @@ "entityType": "indexes", "table": "job_runs" }, + { + "columns": [ + { + "value": "action_key", + "isExpression": false + }, + { + "value": "\"state\" desc", + "isExpression": true + }, + { + "value": "\"queued_at\" desc", + "isExpression": true + }, + { + "value": "\"id\" desc", + "isExpression": true + } + ], + "isUnique": false, + "where": "\"job_runs\".\"state\" IN ('queued', 'running')", + "origin": "manual", + "name": "job_runs_action_active_idx", + "entityType": "indexes", + "table": "job_runs" + }, + { + "columns": [ + { + "value": "action_key", + "isExpression": false + }, + { + "value": "payload_json", + "isExpression": false + }, + { + "value": "\"queued_at\" desc", + "isExpression": true + }, + { + "value": "\"id\" desc", + "isExpression": true + } + ], + "isUnique": false, + "where": "\"job_runs\".\"action_key\" = 'maintenance.rotate-logs' AND length(CAST(\"job_runs\".\"payload_json\" AS BLOB)) <= 128 AND \"job_runs\".\"state\" IN ('cancelled', 'failed', 'succeeded', 'timed-out')", + "origin": "manual", + "name": "job_runs_action_payload_terminal_idx", + "entityType": "indexes", + "table": "job_runs" + }, { "columns": [ { diff --git a/greenfield/scripts/delivery/installProductionSystemdUnits.test.ts b/greenfield/scripts/delivery/installProductionSystemdUnits.test.ts index a7c0e4400..46454028a 100644 --- a/greenfield/scripts/delivery/installProductionSystemdUnits.test.ts +++ b/greenfield/scripts/delivery/installProductionSystemdUnits.test.ts @@ -187,71 +187,75 @@ describe("production systemd unit installation", () => { installationLifecycleTestTimeoutMs ); - test("rejects an untrusted destination and a destination identity swap", async () => { - const fixture = await installationFixture(); - await withDeploymentLease(fixture.state.stateDirectory, async (lease) => { - const paths = await prepareProductionDeliveryDirectories(fixture.state); - const runtime = await installProductionRuntime( - lease, - paths, - runtimeIdentity, - { - probeRuntime: () => Promise.resolve(runtimeIdentity), - sourceExecutable: fixture.runtimeSource, - } - ); - const release = await publishProductionRelease( - lease, - paths, - fixture.sourceRelease, - runtime.identity - ); - const dependencies = { - execute: () => Promise.resolve(successfulSystemctl()), - homeDirectory: fixture.homeDirectory, - userUnitDirectory: fixture.userUnitDirectory, - }; - await installPublishedProductionSystemdUnits( - lease, - paths, - release, - dependencies - ); - const webUnit = path.join( - fixture.userUnitDirectory, - "mira-dashboard-web.service" - ); - const displaced = `${webUnit}.displaced`; - const swapFailure = await rejectionError( - installPublishedProductionSystemdUnits(lease, paths, release, { - ...dependencies, - filesystemTestHooks: { - async beforeRename(fileName) { - if (fileName !== "mira-dashboard-web.service") return; - await rename(webUnit, displaced); - await symlink(displaced, webUnit); - }, - }, - }) - ); - expect(swapFailure.message).toBe( - "Production systemd unit installation failed" - ); - - await chmod(fixture.userUnitDirectory, 0o733); - const permissionFailure = await rejectionError( - installPublishedProductionSystemdUnits( + test( + "rejects an untrusted destination and a destination identity swap", + async () => { + const fixture = await installationFixture(); + await withDeploymentLease(fixture.state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(fixture.state); + const runtime = await installProductionRuntime( + lease, + paths, + runtimeIdentity, + { + probeRuntime: () => Promise.resolve(runtimeIdentity), + sourceExecutable: fixture.runtimeSource, + } + ); + const release = await publishProductionRelease( + lease, + paths, + fixture.sourceRelease, + runtime.identity + ); + const dependencies = { + execute: () => Promise.resolve(successfulSystemctl()), + homeDirectory: fixture.homeDirectory, + userUnitDirectory: fixture.userUnitDirectory, + }; + await installPublishedProductionSystemdUnits( lease, paths, release, dependencies - ) - ); - expect(permissionFailure.message).toBe( - "Production systemd unit installation failed" - ); - }); - }); + ); + const webUnit = path.join( + fixture.userUnitDirectory, + "mira-dashboard-web.service" + ); + const displaced = `${webUnit}.displaced`; + const swapFailure = await rejectionError( + installPublishedProductionSystemdUnits(lease, paths, release, { + ...dependencies, + filesystemTestHooks: { + async beforeRename(fileName) { + if (fileName !== "mira-dashboard-web.service") return; + await rename(webUnit, displaced); + await symlink(displaced, webUnit); + }, + }, + }) + ); + expect(swapFailure.message).toBe( + "Production systemd unit installation failed" + ); + + await chmod(fixture.userUnitDirectory, 0o733); + const permissionFailure = await rejectionError( + installPublishedProductionSystemdUnits( + lease, + paths, + release, + dependencies + ) + ); + expect(permissionFailure.message).toBe( + "Production systemd unit installation failed" + ); + }); + }, + installationLifecycleTestTimeoutMs + ); test("parses only the exact project, release, runtime, and user-unit arguments", () => { const homeDirectory = "/home/dashboard"; diff --git a/greenfield/scripts/documentation/jsonSchema.ts b/greenfield/scripts/documentation/jsonSchema.ts index 660753a61..f003d81a2 100644 --- a/greenfield/scripts/documentation/jsonSchema.ts +++ b/greenfield/scripts/documentation/jsonSchema.ts @@ -130,8 +130,17 @@ import { newestJobRunOrderIsStable, } from "../../src/contracts/jobs.ts"; import { + logMaintenanceActiveRunIsConsistent, + logMaintenanceExecutionOutcomeIsConsistent, + logMaintenanceExecutionTimesAreConsistent, + logMaintenanceJobResultIsConsistent, + logMaintenanceLastRunIsConsistent, + logMaintenanceOutputIsConsistent, + logMaintenancePolicyStatusIsConsistent, logLinesHaveUniqueIds, logMaintenancePoliciesHaveUniqueIds, + logMaintenanceRequestIsConsistent, + logMaintenanceTerminalRunIsConsistent, logSourcesHaveUniqueIds, } from "../../src/contracts/logs.ts"; import { @@ -262,6 +271,42 @@ const runtimeCheckComments = new Map([ logMaintenancePoliciesHaveUniqueIds, "Live Valibot validation additionally requires every fixed log-maintenance policy ID to be unique.", ], + [ + logMaintenanceExecutionTimesAreConsistent, + "Live Valibot validation additionally requires the maintenance finish timestamp not to precede its start timestamp.", + ], + [ + logMaintenanceExecutionOutcomeIsConsistent, + "Live Valibot validation additionally requires a reported successful maintenance execution to contain no failed actions.", + ], + [ + logMaintenanceActiveRunIsConsistent, + "Live Valibot validation additionally requires an active maintenance run to be queued or running.", + ], + [ + logMaintenanceTerminalRunIsConsistent, + "Live Valibot validation additionally requires a last maintenance run to be terminal.", + ], + [ + logMaintenanceLastRunIsConsistent, + "Live Valibot validation additionally permits a summary only for a successful non-dry-run maintenance result whose summary reports success.", + ], + [ + logMaintenancePolicyStatusIsConsistent, + "Live Valibot validation additionally requires each maintenance status to agree with its fixed policy scope, maintenance action identity, and docker-only summary authority.", + ], + [ + logMaintenanceRequestIsConsistent, + "Live Valibot validation additionally permits dry-run mode only for the fixed docker-managed policy.", + ], + [ + logMaintenanceOutputIsConsistent, + "Live Valibot validation additionally permits a queued dry-run result only for the fixed docker-managed policy.", + ], + [ + logMaintenanceJobResultIsConsistent, + "Live Valibot validation additionally requires a successful durable maintenance result to agree with its fixed policy, dry-run mode, successful summary, and completion time.", + ], [ terminalPathIsCanonical, "Live Valibot validation additionally rejects dot and parent segments in the root-relative initial terminal path.", diff --git a/greenfield/src/app/dashboardLogs.ts b/greenfield/src/app/dashboardLogs.ts index ef825fd5d..f1e05d8d8 100644 --- a/greenfield/src/app/dashboardLogs.ts +++ b/greenfield/src/app/dashboardLogs.ts @@ -7,6 +7,7 @@ import type { JobRepository } from "../server/domains/jobs/repository.ts"; import { createSqliteLogMaintenanceAuditWriter } from "../server/domains/logs/operationAudit.ts"; import { createLogsService, type LogsService } from "../server/domains/logs/service.ts"; import { createLogMaintenanceAvailabilityProbe } from "../server/platform/logs/logMaintenanceAvailability.ts"; +import { createLogRotationEpochProbe } from "../server/platform/logs/logRotationEpochProbe.ts"; import { createSafeLogReader } from "../server/platform/logs/safeLogReader.ts"; import { createLogSourceCatalog } from "../server/platform/logs/sourceCatalog.ts"; @@ -16,11 +17,12 @@ export interface DashboardLogsOptions { readonly database: SQLiteBunDatabase; readonly jobRepository: Pick< JobRepository, - "enqueueManualRun" | "findRunByIdempotency" + "enqueueManualRun" | "findRunByIdempotency" | "readActionPayloadRunSnapshots" >; readonly logMaintenanceRoot: string; readonly now?: () => Date; readonly onAuditSettlementFailure?: (fields: { + readonly dryRun: boolean; readonly policyId: LogMaintenancePolicyId; readonly settlement: "failed" | "queued"; }) => void; @@ -63,6 +65,12 @@ export function createDashboardLogsService(options: DashboardLogsOptions): LogsS : { onAuditSettlementFailure: options.onAuditSettlementFailure, }), - reader: createSafeLogReader(catalog, nowMs), + reader: createSafeLogReader( + catalog, + nowMs, + createLogRotationEpochProbe({ + logMaintenanceRoot: options.logMaintenanceRoot, + }) + ), }); } diff --git a/greenfield/src/app/dashboardServer.test.ts b/greenfield/src/app/dashboardServer.test.ts index cc6d7e437..4b8279dfe 100644 --- a/greenfield/src/app/dashboardServer.test.ts +++ b/greenfield/src/app/dashboardServer.test.ts @@ -486,19 +486,24 @@ describe("Dashboard workspace operations composition", () => { chmod(dashboardLogsRoot, 0o700), chmod(logMaintenanceRoot, 0o700), ]); - let repositoryCalls = 0; + let repositoryReadCalls = 0; + let repositoryUnsafeCalls = 0; let settlementFailures = 0; const jobRepository: Pick< JobRepository, - "enqueueManualRun" | "findRunByIdempotency" + "enqueueManualRun" | "findRunByIdempotency" | "readActionPayloadRunSnapshots" > = { enqueueManualRun: () => { - repositoryCalls += 1; + repositoryUnsafeCalls += 1; throw new Error("Read-only composition must not enqueue"); }, findRunByIdempotency: () => { - repositoryCalls += 1; - throw new Error("Read-only composition must not query jobs"); + repositoryUnsafeCalls += 1; + throw new Error("Read-only composition must not replay an enqueue"); + }, + readActionPayloadRunSnapshots: ({ payloadJsons }) => { + repositoryReadCalls += 1; + return payloadJsons.map((payloadJson) => ({ payloadJson })); }, }; @@ -543,7 +548,8 @@ describe("Dashboard workspace operations composition", () => { expect( maintenance.policies.every(({ state }) => state === "unavailable") ).toBe(true); - expect(repositoryCalls).toBe(0); + expect(repositoryReadCalls).toBe(1); + expect(repositoryUnsafeCalls).toBe(0); expect(settlementFailures).toBe(0); } finally { await Promise.all([ diff --git a/greenfield/src/app/dashboardServer.ts b/greenfield/src/app/dashboardServer.ts index 8d558a6de..a271cab00 100644 --- a/greenfield/src/app/dashboardServer.ts +++ b/greenfield/src/app/dashboardServer.ts @@ -628,12 +628,13 @@ export async function createDashboardServer( jobRepository, logMaintenanceRoot: options.dashboardLogMaintenanceRoot, ...(domainNow === undefined ? {} : { now: domainNow }), - onAuditSettlementFailure: ({ policyId, settlement }) => + onAuditSettlementFailure: ({ dryRun, policyId, settlement }) => options.applicationRuntime.logger.error({ component: "logs-maintenance-audit", event: "logs.maintenance.audit_settlement_failed", fields: { kind: "logs-maintenance-audit-settlement", + dryRun, policyId, settlement, }, diff --git a/greenfield/src/app/worker.test.ts b/greenfield/src/app/worker.test.ts index b4a83b324..def03c9db 100644 --- a/greenfield/src/app/worker.test.ts +++ b/greenfield/src/app/worker.test.ts @@ -119,7 +119,7 @@ function processFixture( }); const logMaintenance = Object.freeze({ availablePolicies: () => Promise.resolve(["docker-managed" as const]), - run: () => Promise.resolve(), + run: () => Promise.resolve(undefined), }); const runtime: DashboardWorkerRuntime = Object.freeze({ completion, diff --git a/greenfield/src/browser/application.test.tsx b/greenfield/src/browser/application.test.tsx index 281bc31aa..6fd92cee0 100644 --- a/greenfield/src/browser/application.test.tsx +++ b/greenfield/src/browser/application.test.tsx @@ -2,11 +2,14 @@ import { describe, expect, jest, test } from "bun:test"; import { createMemoryHistory } from "@tanstack/react-router"; +import type { AuthStatus } from "../contracts/auth.ts"; +import { deriveGatewaySessionStats } from "../contracts/gatewaySessions.ts"; import { createDashboardQueryClient } from "./api/queryClient.ts"; import { createDashboardTrpcClient } from "./api/trpcClient.ts"; import { DashboardBrowserApplication } from "./application.tsx"; import { authStatusQueryKey } from "./auth/authQueries.ts"; import { createDashboardBrowserCollections } from "./data/dashboardCollections.ts"; +import { jobQueueSummaryQueryKey } from "./jobs/jobQueries.ts"; import { notificationLatestQueryKey } from "./notifications/notificationQueries.ts"; import { createDashboardRouter } from "./router.tsx"; import type { DashboardWebAuthnClient } from "./security/webauthn/webauthnClient.ts"; @@ -251,6 +254,271 @@ describe("Dashboard browser application", () => { } }); + test("keeps authenticated header controls mounted across route verification", async () => { + const timestampMs = Date.now(); + const authentication: Extract = { + session: { + authenticatedAtMs: timestampMs, + authMethod: "password", + createdAtMs: timestampMs, + expiresAtMs: timestampMs + 86_400_000, + id: "a".repeat(32), + isCurrent: true, + lastSeenAtMs: timestampMs, + userAgent: "Dashboard navigation test", + }, + state: "authenticated", + user: { + id: "019fd974-54a2-74dd-a64b-d4186f8d8828", + username: "operator", + }, + }; + const secondAuthenticationCheck = Promise.withResolvers(); + let authenticationCalls = 0; + let deferAuthenticationChecks = false; + let gatewayStatusCalls = 0; + let notificationCalls = 0; + let workerStatusCalls = 0; + const queryClient = createDashboardQueryClient(); + const router = createDashboardRouter( + createMemoryHistory({ initialEntries: ["/agents"] }) + ); + const readinessFetch = jest + .spyOn(globalThis, "fetch") + .mockResolvedValue(Response.json({ status: "ready" })); + const trpcClient = createDashboardTrpcClient({ + mutation(path) { + return Promise.reject(new TypeError(`Unexpected mutation: ${path}`)); + }, + query(path) { + switch (path) { + case "auth.status": { + authenticationCalls += 1; + return deferAuthenticationChecks + ? secondAuthenticationCheck.promise + : Promise.resolve(authentication); + } + case "agents.getConfiguration": { + return Promise.resolve({ agents: [] }); + } + case "agents.listStatuses": { + return Promise.resolve({ statuses: [] }); + } + case "agents.listTaskHistory": { + return Promise.resolve({ runs: [] }); + } + case "gateway.connection.get": { + gatewayStatusCalls += 1; + return Promise.resolve({ + checkedAtMs: timestampMs, + connectedAtMs: timestampMs, + connectionGeneration: 1, + freshness: "fresh" as const, + lastActivityAtMs: timestampMs, + phase: "connected" as const, + reconnectAttempt: 0, + }); + } + case "gatewaySessions.list": { + return Promise.resolve({ + filter: "ALL" as const, + projectionTruncated: false, + sessions: [], + source: { + checkedAtMs: timestampMs, + connection: "connected" as const, + freshness: "fresh" as const, + observedAtMs: timestampMs, + }, + stats: deriveGatewaySessionStats([], timestampMs), + }); + } + case "jobs.listRuns": { + workerStatusCalls += 1; + return Promise.resolve({ + runs: [], + summary: { + activeResourceClasses: [], + control: { + claimingPaused: false, + updatedAtMs: timestampMs, + version: 1, + }, + stateCounts: { + cancelled: 0, + failed: 0, + queued: 0, + running: 0, + succeeded: 0, + "timed-out": 0, + }, + workers: [], + }, + }); + } + case "notifications.list": { + notificationCalls += 1; + return Promise.resolve({ + notifications: [], + readCount: 0, + unreadCount: 0, + }); + } + default: { + return Promise.reject(new TypeError(`Unexpected query: ${path}`)); + } + } + }, + }); + const collections = createDashboardBrowserCollections(queryClient, trpcClient); + const view = render( + + ); + + try { + expect( + await screen.findByRole("heading", { level: 1, name: "Agents" }) + ).toBeVisible(); + const logoutButton = screen.getByRole("button", { name: "Log out" }); + const statusButton = screen.getByRole("button", { + name: /^System status:/u, + }); + const notificationButton = await screen.findByRole("button", { + name: "Notifications, none unread", + }); + await userEvent.click(notificationButton); + const notificationHeading = await screen.findByRole("heading", { + level: 2, + name: "Notifications", + }); + const authenticationCallsBeforeNavigation = authenticationCalls; + const gatewayStatusCallsBeforeNavigation = gatewayStatusCalls; + const notificationCallsBeforeNavigation = notificationCalls; + const readinessCallsBeforeNavigation = readinessFetch.mock.calls.length; + const workerStatusCallsBeforeNavigation = workerStatusCalls; + deferAuthenticationChecks = true; + + await act(async () => { + await router.navigate({ to: "/sessions" }); + }); + await waitFor(() => + expect(authenticationCalls).toBeGreaterThan( + authenticationCallsBeforeNavigation + ) + ); + + expect(router.state.location.pathname).toBe("/sessions"); + expect(screen.getByRole("button", { name: "Log out" })).toBe(logoutButton); + expect(screen.getByRole("button", { name: /^System status:/u })).toBe( + statusButton + ); + expect( + screen.getByRole("button", { name: "Notifications, none unread" }) + ).toBe(notificationButton); + expect(notificationButton).toBeVisible(); + expect(notificationButton).toHaveAttribute("aria-expanded", "true"); + expect(notificationHeading).toBeVisible(); + expect(gatewayStatusCalls).toBe(gatewayStatusCallsBeforeNavigation); + expect(notificationCalls).toBe(notificationCallsBeforeNavigation); + expect(readinessFetch).toHaveBeenCalledTimes(readinessCallsBeforeNavigation); + expect(workerStatusCalls).toBe(workerStatusCallsBeforeNavigation); + + await act(async () => { + secondAuthenticationCheck.resolve(authentication); + await secondAuthenticationCheck.promise; + }); + expect( + await screen.findByRole("heading", { level: 1, name: "Sessions" }) + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Log out" })).toBe(logoutButton); + expect(screen.getByRole("button", { name: /^System status:/u })).toBe( + statusButton + ); + expect( + screen.getByRole("button", { name: "Notifications, none unread" }) + ).toBe(notificationButton); + expect(notificationButton).toHaveAttribute("aria-expanded", "true"); + expect(notificationHeading).toBeVisible(); + expect(gatewayStatusCalls).toBe(gatewayStatusCallsBeforeNavigation); + expect(notificationCalls).toBe(notificationCallsBeforeNavigation); + expect(readinessFetch).toHaveBeenCalledTimes(readinessCallsBeforeNavigation); + expect(workerStatusCalls).toBe(workerStatusCallsBeforeNavigation); + + act(() => { + queryClient.setQueryData(jobQueueSummaryQueryKey, { + runs: [], + summary: { + activeResourceClasses: [], + control: { + claimingPaused: false, + updatedAtMs: timestampMs, + version: 2, + }, + stateCounts: { + cancelled: 0, + failed: 0, + queued: 0, + running: 0, + succeeded: 0, + "timed-out": 0, + }, + workers: [ + { + activeRunCount: 0, + capacity: 1, + heartbeatAtMs: timestampMs, + id: "019fe300-0000-7000-8000-000000000001", + releaseId: "a".repeat(40), + startedAtMs: timestampMs, + state: "online" as const, + }, + ], + }, + }); + queryClient.setQueryData(notificationLatestQueryKey, { + notifications: [], + readCount: 4, + unreadCount: 2, + }); + }); + await waitFor(() => { + expect(statusButton).toHaveAttribute( + "aria-label", + "System status: all systems online. Open details" + ); + expect(notificationButton).toHaveAttribute( + "aria-label", + "Notifications, 2 unread" + ); + expect(notificationHeading.nextElementSibling).toHaveTextContent( + "2 unread · 4 read" + ); + }); + expect(screen.getByRole("button", { name: "Log out" })).toBe(logoutButton); + expect( + screen.getByRole("button", { + name: "System status: all systems online. Open details", + }) + ).toBe(statusButton); + expect(screen.getByRole("button", { name: "Notifications, 2 unread" })).toBe( + notificationButton + ); + } finally { + secondAuthenticationCheck.resolve(authentication); + view.unmount(); + await collections.cleanup(); + queryClient.clear(); + readinessFetch.mockRestore(); + } + }); + test("creates isolated query caches with the reviewed browser defaults", () => { const first = createDashboardQueryClient(); const second = createDashboardQueryClient(); diff --git a/greenfield/src/browser/chat/ChatBrowser.tsx b/greenfield/src/browser/chat/ChatBrowser.tsx index 268306697..cddc876cc 100644 --- a/greenfield/src/browser/chat/ChatBrowser.tsx +++ b/greenfield/src/browser/chat/ChatBrowser.tsx @@ -505,7 +505,14 @@ export function ChatBrowser({ message.idempotencyKey === undefined ? [] : [message.idempotencyKey] ), runIds: canonicalMessages.flatMap((message) => - message.runId === undefined ? [] : [message.runId] + message.role !== "assistant" || message.runId === undefined + ? [] + : [message.runId] + ), + providerRunIds: canonicalMessages.flatMap((message) => + message.role !== "assistant" || message.providerRunId === undefined + ? [] + : [message.providerRunId] ), throughCursor: runtimeStore.cursorFor(selectedSessionKey), }); diff --git a/greenfield/src/browser/chat/ChatLiveProjection.test.tsx b/greenfield/src/browser/chat/ChatLiveProjection.test.tsx index aced24e91..a9cdd93d8 100644 --- a/greenfield/src/browser/chat/ChatLiveProjection.test.tsx +++ b/greenfield/src/browser/chat/ChatLiveProjection.test.tsx @@ -15,6 +15,68 @@ const display = { }; describe("live OpenClaw chat projection", () => { + test("keeps compaction as one stable lifecycle row and refreshes retry age", () => { + const store = createChatRuntimeStore(); + const projection = ( + text: "Compacting context" | "Context compacted", + occurredAtMs: number + ) => + projectChatExternalRun({ + continuity: "complete", + hasUnprojectedActivity: false, + observationEpoch: occurredAtMs, + observedAtMs: occurredAtMs, + parts: [ + { + id: "compaction:live-compaction", + kind: "item", + occurredAtMs, + sequence: 1, + text, + type: "compaction", + }, + ], + projectionTruncated: false, + providerRunId: "live-compaction", + sessionKey, + source: "provider-runtime", + text: "", + updatedAtMs: occurredAtMs, + }); + + store.installExternalRuns(sessionKey, [projection("Compacting context", 1000)]); + store.installExternalRuns(sessionKey, [projection("Compacting context", 2000)]); + let messages = chatRuntimeMessages(store.state, sessionKey); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + parts: [ + { + activity: "running", + kind: "control", + text: "Compacting context", + }, + ], + timestampMs: 2000, + }); + + store.installExternalRuns(sessionKey, [projection("Context compacted", 3000)]); + messages = chatRuntimeMessages(store.state, sessionKey); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + parts: [ + { + activity: "complete", + kind: "control", + text: "Context compacted", + }, + ], + timestampMs: 3000, + }); + expect(messages[0]?.id).toBe( + "external:agent:main:main:live-compaction:segment:compaction:compaction:live-compaction" + ); + }); + test("renders exact provider order and complete session.tool bubbles without raw items", () => { const projection = projectChatExternalRun({ continuity: "complete", @@ -124,18 +186,16 @@ describe("live OpenClaw chat projection", () => { }); const store = createChatRuntimeStore(); store.installExternalRuns(sessionKey, [projection]); - const message = chatRuntimeMessages(store.state, sessionKey)[0]!; + const messages = chatRuntimeMessages(store.state, sessionKey); - expect(message.parts.map(({ kind }) => kind)).toEqual([ - "thinking", - "thinking", - "tool", - "thinking", - "text", - "tool", - "text", - ]); - expect(message.parts.filter(({ kind }) => kind === "tool")).toEqual([ + expect( + messages.flatMap((message) => message.parts.map(({ kind }) => kind)) + ).toEqual(["thinking", "thinking", "tool", "thinking", "text", "tool", "text"]); + expect( + messages + .flatMap((message) => message.parts) + .filter(({ kind }) => kind === "tool") + ).toEqual([ expect.objectContaining({ callId: "command-1", input: '{"cmd":"pwd","workdir":"/workspace"}', @@ -151,8 +211,17 @@ describe("live OpenClaw chat projection", () => { ]); const rendered = render( - +
+ {messages.map((message) => ( + + ))} +
); + expect(screen.getAllByRole("article")).toHaveLength(7); const tools = screen.getAllByRole("region", { name: "Bash, completed" }); expect(tools).toHaveLength(2); for (const tool of tools) { @@ -160,9 +229,9 @@ describe("live OpenClaw chat projection", () => { expect(within(tool).getAllByText("Tool input")).toHaveLength(1); expect(within(tool).getAllByText("Tool output")).toHaveLength(1); } - expect(within(tools[0]!).getByText("pwd (workspace)")).toBeVisible(); + expect(within(tools[0]!).getAllByText("pwd (workspace)")).toHaveLength(2); expect(within(tools[0]!).getByText("/workspace")).toBeVisible(); - expect(within(tools[1]!).getByText("bun test (workspace)")).toBeVisible(); + expect(within(tools[1]!).getAllByText("bun test (workspace)")).toHaveLength(2); expect(within(tools[1]!).getByText(/12 pass/iu)).toBeVisible(); expect(screen.queryByText(/raw-(?:analysis|command|tool)-label/iu)).toBeNull(); const visibleText = document.body.textContent ?? ""; @@ -184,7 +253,7 @@ describe("live OpenClaw chat projection", () => { const mixedWithoutTools = render( ); expect(screen.getByRole("article")).toBeVisible(); @@ -224,4 +293,78 @@ describe("live OpenClaw chat projection", () => { ); expect(screen.queryByRole("article")).toBeNull(); }); + + test("renders an aggregate assistant replacement at the replaced provider position", () => { + const projection = projectChatExternalRun({ + continuity: "complete", + hasUnprojectedActivity: false, + observationEpoch: 1, + observedAtMs: 1000, + parts: [ + { + kind: "thinking", + segmentId: "reasoning-before", + sequence: 1, + text: "Reasoning before.", + }, + { + kind: "assistant", + segmentId: "assistant-replaced", + sequence: 2, + text: "Stale answer.", + }, + { + callId: "command-after", + isError: false, + kind: "tool", + name: "lookup", + phase: "succeeded", + sequence: 3, + }, + { + kind: "thinking", + segmentId: "reasoning-after", + sequence: 4, + text: "Reasoning after.", + }, + ], + projectionTruncated: false, + providerRunId: "aggregate-order-run", + sessionKey, + source: "provider-runtime", + text: "Authoritative answer.", + updatedAtMs: 1000, + }); + expect( + projection.segments?.find( + ({ segmentId }) => segmentId === "aggregate:assistant" + )?.providerSequence + ).toBe(2); + const store = createChatRuntimeStore(); + store.installExternalRuns(sessionKey, [projection]); + const messages = chatRuntimeMessages(store.state, sessionKey); + render( +
+ {messages.map((message) => ( + + ))} +
+ ); + + const visibleText = document.body.textContent ?? ""; + const visibleOrder = [ + "Reasoning before.", + "Authoritative answer.", + "Lookup", + "Reasoning after.", + ].map((text) => visibleText.indexOf(text)); + expect(visibleOrder.every((index) => index >= 0)).toBeTrue(); + expect(visibleOrder).toEqual( + [...visibleOrder].toSorted((left, right) => left - right) + ); + }); }); diff --git a/greenfield/src/browser/chat/ChatMessageBubble.test.tsx b/greenfield/src/browser/chat/ChatMessageBubble.test.tsx index 9e1e72123..f7389465e 100644 --- a/greenfield/src/browser/chat/ChatMessageBubble.test.tsx +++ b/greenfield/src/browser/chat/ChatMessageBubble.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, jest, test } from "bun:test"; import { safeChatMarkdownLink } from "./chatMarkdownPolicy.ts"; import { ChatMessageBubble } from "./ChatMessageBubble.tsx"; +import { toolDisplayName } from "./chatToolPresentation.ts"; const { render, screen, waitFor, within } = await import("@testing-library/react"); const userEventModule = await import("@testing-library/user-event"); @@ -15,6 +16,54 @@ const display = { }; describe("chat message bubble", () => { + test("renders active and completed activity rows as explicit statuses", () => { + render( + <> + + + + ); + + const running = screen.getByRole("status", { name: "Thinking…" }); + expect(running).toBeVisible(); + expect(running.textContent).toBe("Thinking..."); + expect(screen.getByRole("status", { name: "Context compacted" })).toBeVisible(); + }); + test("presents provider-neutral send admission without claiming queued state", () => { render( { const tool = screen.getByRole("region", { name: "Bash, completed", }); - const toggle = screen.getByRole("button", { name: /Bash completed/iu }); + const toggle = screen.getByRole("button", { + name: "Bash bun test (mira-dashboard) completed", + }); expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(within(tool).getByText("bun test (mira-dashboard)")).toBeVisible(); expect(within(tool).queryByText("Tool output")).toBeNull(); await user.click(toggle); expect(within(tool).getByText("Description")).toBeVisible(); - expect(within(tool).getByText("bun test (mira-dashboard)")).toBeVisible(); + expect(within(tool).getAllByText("bun test (mira-dashboard)")).toHaveLength(2); expect(within(tool).getByText("Tool input")).toBeVisible(); expect(within(tool).getByText("Tool output")).toBeVisible(); expect(within(tool).getByText(/8 pass/iu)).toBeVisible(); @@ -192,6 +244,120 @@ describe("chat message bubble", () => { expect(within(tool).queryByText("running")).toBeNull(); }); + test("bounds collapsed tool summaries without exposing unrelated input or output", () => { + const command = `bun test ${"safe ".repeat(40)}`; + render( + + ); + + const tool = screen.getByRole("region", { name: "Bash, completed" }); + const summary = within(tool).getByText(/^bun test safe/iu); + expect(summary.textContent).not.toContain("\n"); + // oxlint-disable-next-line unicorn/prefer-spread -- The summary contract is explicitly bounded in Unicode code points. + expect(Array.from(summary.textContent ?? "")).toHaveLength(120); + expect(within(tool).queryByText("input-secret")).toBeNull(); + expect(within(tool).queryByText("output-secret")).toBeNull(); + expect(tool.getAttribute("aria-label")).toBe("Bash, completed"); + }); + + test("strips Unicode controls from summaries while retaining full tool input", async () => { + const user = userEvent.setup(); + const command = "bun\u202E test\u0007 --safe"; + render( + + ); + + const tool = screen.getByRole("region", { name: "Bash, completed" }); + const toggle = within(tool).getByRole("button", { + name: "Bash bun test --safe completed", + }); + expect(toggle.textContent).not.toContain("\u202E"); + expect(toggle.textContent).not.toContain("\u0007"); + await user.click(toggle); + const fullInput = within(tool).getByRole("region", { + name: "Bash tool input", + }).textContent; + expect(fullInput).toContain("\u202E"); + expect(fullInput).toContain(String.raw`\u0007`); + }); + + test("bounds and strips Unicode controls from tool names and accessible labels", () => { + const name = `functions.unsafe\u202E_tool\u0007_${"x".repeat(160)}`; + const label = toolDisplayName(name); + expect(label).toHaveLength(120); + expect(label).not.toContain("\u202E"); + expect(label).not.toContain("\u0007"); + const expandingLabel = toolDisplayName(`ß${"x".repeat(119)}`); + expect(expandingLabel).toHaveLength(120); + expect(expandingLabel.startsWith("SS")).toBeTrue(); + expect(expandingLabel.endsWith("…")).toBeTrue(); + render( + + ); + + const tool = screen.getByRole("region", { name: `${label}, completed` }); + expect(within(tool).getByRole("button")).toHaveAccessibleName( + `${label} completed` + ); + }); + test("derives tool descriptions only from valid structured string input", () => { render( >; - let command: string | undefined; - if (typeof input.command === "string") command = input.command; - else if (typeof input.cmd === "string") command = input.cmd; - if (command !== undefined) { - let workingDirectory: string | undefined; - if (typeof input.workdir === "string") workingDirectory = input.workdir; - else if (typeof input.cwd === "string") workingDirectory = input.cwd; - const directoryName = workingDirectory?.split(/[\\/]/u).findLast(Boolean); - return directoryName === undefined ? command : `${command} (${directoryName})`; - } - return typeof input.path === "string" ? input.path : undefined; -} - function ToolDetailSection({ children, label, @@ -147,7 +112,14 @@ function ToolPart({ expanded, part }: ToolPartProps) { size="sm" tone={part.status === "failed" ? "danger" : "inherit"} /> - {label} + + {label} + {description !== undefined && ( + + {description} + + )} + {part.status} + {part.activity === "running" ? ( + + ) : ( + part.text + )} + + ); + } return (

{ } describe("chat transcript", () => { + test("projects one default activity row before the first assistant part", () => { + const projected = projectChatTranscriptMessages( + [], + ["provider-empty"], + sessionKey, + Date.now() + ); + + expect(projected).toEqual([ + expect.objectContaining({ + id: "activity:provider-empty:thinking", + parts: [ + { + activity: "running", + kind: "control", + text: "Thinking…", + tone: "muted", + }, + ], + }), + ]); + }); + + test("keeps activity after a same-run steer and derives only bounded tool input", () => { + const tool: ChatDisplayMessage = { + attachments: [], + id: "external:run:segment:tool", + parts: [ + { + callId: "call-1", + input: { + cmd: `bun test ${"safe ".repeat(40)}`, + ignoredSecret: "must-not-render", + }, + kind: "tool", + name: "functions.exec_command", + output: "must-not-render-in-activity", + status: "completed", + }, + ], + providerRunId: "provider-steer", + role: "assistant", + sequence: 1, + sessionKey, + }; + const steer: ChatDisplayMessage = { + attachments: [], + id: "steer-user", + parts: [{ kind: "text", text: "Continue" }], + providerRunId: "provider-steer", + role: "user", + sequence: 2, + sessionKey, + }; + const projected = projectChatTranscriptMessages( + [tool, steer], + ["provider-steer"], + sessionKey, + Date.now() + ); + + expect(projected.map(({ id }) => id)).toEqual([ + tool.id, + steer.id, + "activity:provider-steer:thinking", + ]); + expect(projected.at(-1)?.parts).toEqual([ + expect.objectContaining({ kind: "control", text: "Thinking…" }), + ]); + expect(JSON.stringify(projected.at(-1))).not.toContain("must-not-render"); + }); + + test("shows the latest bounded tool summary as a separate activity row", () => { + const runningTool: ChatDisplayMessage = { + attachments: [], + id: "external:run:segment:tool", + parts: [ + { + callId: "call-2", + input: '{"cmd":"bun test src/browser/chat","cwd":"/workspace"}', + kind: "tool", + name: "functions.exec_command", + status: "running", + }, + ], + providerRunId: "provider-tool", + role: "assistant", + sequence: 1, + sessionKey, + }; + const projected = projectChatTranscriptMessages( + [runningTool], + ["provider-tool"], + sessionKey, + Date.now() + ); + + expect(projected.map(({ id }) => id)).toEqual([ + runningTool.id, + "activity:provider-tool:tool:call-2", + ]); + expect(projected.at(-1)?.parts).toEqual([ + expect.objectContaining({ + kind: "control", + text: "Bash: bun test src/browser/chat (workspace)", + }), + ]); + }); + + test("bounds active and completed compaction feedback like legacy", () => { + const nowMs = Date.now(); + const compaction = ( + id: string, + activity: "complete" | "running", + timestampMs: number + ): ChatDisplayMessage => ({ + attachments: [], + id, + parts: [ + { + activity, + kind: "control", + text: + activity === "running" + ? "Compacting context" + : "Context compacted", + tone: "muted", + }, + ], + providerRunId: id, + role: "assistant", + sequence: 1, + sessionKey, + timestampMs, + }); + const projected = projectChatTranscriptMessages( + [ + compaction("fresh-complete", "complete", nowMs - 1000), + compaction("stale-complete", "complete", nowMs - 6000), + compaction("stale-active", "running", nowMs - 300_001), + ], + [], + sessionKey, + nowMs + ); + + expect(projected.map(({ id }) => id)).toEqual(["fresh-complete"]); + expect(projected[0]?.parts).toEqual([ + expect.objectContaining({ + activity: "complete", + kind: "control", + text: "Context compacted", + }), + ]); + }); + + test("starts a fresh compaction TTL after idle and refreshes it on retry", () => { + jest.useFakeTimers(); + let currentTimeMs = 1000; + const nowSpy = spyOn(Date, "now").mockImplementation(() => currentTimeMs); + const compaction = (timestampMs: number): ChatDisplayMessage => ({ + attachments: [], + id: "compaction-retry", + parts: [ + { + activity: "running", + kind: "control", + text: "Compacting context", + tone: "muted", + }, + ], + providerRunId: "provider-compaction", + role: "assistant", + sequence: 1, + sessionKey, + timestampMs, + }); + const rendered = render(); + + try { + currentTimeMs = 1_000_000; + act(() => { + rendered.rerender( + + ); + }); + expect(screen.queryByText("No messages yet")).toBeNull(); + + const dateReadsBeforeUnrelatedMessage = nowSpy.mock.calls.length; + act(() => { + rendered.rerender( + + ); + }); + expect(nowSpy).toHaveBeenCalledTimes(dateReadsBeforeUnrelatedMessage); + + currentTimeMs += activeCompactionMaximumAgeMs - 1000; + act(() => { + jest.advanceTimersByTime(activeCompactionMaximumAgeMs - 1000); + }); + expect(screen.queryByText("No messages yet")).toBeNull(); + + act(() => { + rendered.rerender( + + ); + }); + currentTimeMs += 2000; + act(() => { + jest.advanceTimersByTime(2000); + }); + expect(screen.queryByText("No messages yet")).toBeNull(); + + currentTimeMs += activeCompactionMaximumAgeMs - 2000; + act(() => { + jest.advanceTimersByTime(activeCompactionMaximumAgeMs - 2000); + }); + expect(screen.getByText("No messages yet")).toBeVisible(); + } finally { + act(() => rendered.unmount()); + nowSpy.mockRestore(); + jest.useRealTimers(); + } + }); + + test("filters an expired compaction synchronously when it arrives after idle", () => { + jest.useFakeTimers(); + let currentTimeMs = 1000; + const nowSpy = spyOn(Date, "now").mockImplementation(() => currentTimeMs); + const rendered = render(); + const expiredCompaction: ChatDisplayMessage = { + attachments: [], + id: "expired-after-idle", + parts: [ + { + activity: "complete", + kind: "control", + text: "Context compacted", + tone: "muted", + }, + ], + providerRunId: "expired-provider-run", + role: "assistant", + sequence: 1, + sessionKey, + timestampMs: 1_000_000 - completedCompactionMaximumAgeMs - 1, + }; + + try { + const readsBeforeArrival = nowSpy.mock.calls.length; + currentTimeMs = 1_000_000; + act(() => { + rendered.rerender( + + ); + }); + expect(nowSpy).toHaveBeenCalledTimes(readsBeforeArrival + 1); + expect(screen.queryByText("Context compacted")).toBeNull(); + expect(screen.getByText("No messages yet")).toBeVisible(); + + const readsBeforePendingTimers = nowSpy.mock.calls.length; + act(() => { + jest.advanceTimersByTime(0); + }); + expect(nowSpy).toHaveBeenCalledTimes(readsBeforePendingTimers); + + act(() => { + rendered.rerender( + + ); + }); + expect(nowSpy).toHaveBeenCalledTimes(readsBeforePendingTimers); + } finally { + act(() => rendered.unmount()); + nowSpy.mockRestore(); + jest.useRealTimers(); + } + }); + test("removes hidden tool-only messages from the virtualized row set", () => { const toolOnly: ChatDisplayMessage = { attachments: [], diff --git a/greenfield/src/browser/chat/ChatTranscript.tsx b/greenfield/src/browser/chat/ChatTranscript.tsx index 4c93dd5e9..06b76031a 100644 --- a/greenfield/src/browser/chat/ChatTranscript.tsx +++ b/greenfield/src/browser/chat/ChatTranscript.tsx @@ -1,5 +1,5 @@ import { ArrowDown, MessagesSquare } from "lucide-react"; -import { useEffect, useEffectEvent, useState } from "react"; +import { useEffect, useEffectEvent, useLayoutEffect, useState } from "react"; /* eslint-disable jsx-a11y/no-noninteractive-tabindex -- The scrollable transcript log must be keyboard-focusable. */ import { Button } from "../ui/Button.tsx"; @@ -9,6 +9,11 @@ import { LoadingState } from "../ui/LoadingState.tsx"; import { Virtualizer, type VirtualizerItemsAppendedEvent } from "../ui/Virtualizer.tsx"; import { ChatMessageBubble } from "./ChatMessageBubble.tsx"; import { visibleChatTranscriptMessages } from "./chatMessageVisibility.ts"; +import { + activeCompactionMaximumAgeMs, + completedCompactionMaximumAgeMs, + projectChatTranscriptMessages, +} from "./chatTranscriptProjection.ts"; import type { ChatDisplayMessage, ChatDisplaySettings, @@ -56,7 +61,10 @@ function messageRevision(message: ChatDisplayMessage): number { `${part.kind}|${part.kind === "thinking" ? part.status : ""}|${part.text}` ); } else if (part.kind === "control") { - hash = hashRevisionPart(hash, `${part.kind}|${part.tone}|${part.text}`); + hash = hashRevisionPart( + hash, + `${part.kind}|${part.tone}|${part.activity ?? ""}|${part.text}` + ); } else { hash = hashRevisionPart( hash, @@ -122,6 +130,29 @@ function emptyTranscriptNotice(sessionKey: string): ChatTranscriptNotice { return { announcement: "", newMessageCount: 0, sessionKey }; } +function compactionTimings(messages: readonly ChatDisplayMessage[]): readonly Readonly<{ + expiresAtMs: number; + revision: readonly [string, number, number, "complete" | "running"]; +}>[] { + return messages.flatMap((message) => { + const timestampMs = message.timestampMs; + if (timestampMs === undefined) return []; + return message.parts.flatMap((part, index) => { + if (part.kind !== "control" || part.activity === undefined) return []; + return [ + { + expiresAtMs: + timestampMs + + (part.activity === "running" + ? activeCompactionMaximumAgeMs + : completedCompactionMaximumAgeMs), + revision: [message.id, timestampMs, index, part.activity] as const, + }, + ]; + }); + }); +} + /** * Virtualizes hydrated chat history while preserving prepend and follow anchors. * @returns One accessible virtual transcript. @@ -147,9 +178,26 @@ export function ChatTranscript({ const [notice, setNotice] = useState(() => emptyTranscriptNotice(sessionKey) ); + const timings = compactionTimings(messages); + const compactionRevision = JSON.stringify(timings.map(({ revision }) => revision)); + const [nowMs, setNowMs] = useState(() => Date.now()); + const nextCompactionExpiry = timings + .map(({ expiresAtMs }) => expiresAtMs) + .filter((expiry) => expiry > nowMs) + .toSorted((left, right) => left - right)[0]; const currentNotice = notice.sessionKey === sessionKey ? notice : emptyTranscriptNotice(sessionKey); - const visibleMessages = visibleChatTranscriptMessages(messages, display, readAloud); + const transcriptMessages = projectChatTranscriptMessages( + messages, + activeRunIds, + sessionKey, + nowMs + ); + const visibleMessages = visibleChatTranscriptMessages( + transcriptMessages, + display, + readAloud + ); const stopReadAloud = useEffectEvent(() => onStopReadAloud?.()); const stopActiveReadAloud = useEffectEvent(() => { if (readAloud?.phase !== "idle") stopReadAloud(); @@ -196,6 +244,19 @@ export function ChatTranscript({ } useEffect(() => () => stopActiveReadAloud(), [sessionKey]); + useLayoutEffect(() => { + const currentTimeMs = Date.now(); + // oxlint-disable-next-line react/react-compiler -- A changed provider lifecycle must refresh the external wall clock before paint so an already-expired status never flashes. + setNowMs((previous) => Math.max(previous, currentTimeMs)); + }, [compactionRevision]); + useEffect(() => { + if (nextCompactionExpiry === undefined) return; + const timeout = globalThis.setTimeout( + () => setNowMs(Date.now()), + Math.max(0, nextCompactionExpiry - nowMs) + ); + return () => globalThis.clearTimeout(timeout); + }, [nextCompactionExpiry, nowMs]); if (visibleMessages.length === 0 && initialLoading) { return ( diff --git a/greenfield/src/browser/chat/chatContractAdapter.ts b/greenfield/src/browser/chat/chatContractAdapter.ts index 283022f77..36172ece4 100644 --- a/greenfield/src/browser/chat/chatContractAdapter.ts +++ b/greenfield/src/browser/chat/chatContractAdapter.ts @@ -6,6 +6,7 @@ import type { } from "../../contracts/chatModel.ts"; import type { ChatExternalRunProjection, + ChatExternalRunSegmentProjection, ChatRuntimeEvent, ChatRuntimeSnapshotProjection, } from "./chatRuntimeStore.ts"; @@ -532,47 +533,100 @@ export function projectChatRuntimeSnapshot( * @returns Honest read-only assistant projection with explicit continuity markers. */ export function projectChatExternalRun(run: ChatExternalRun): ChatExternalRunProjection { - const parts: ChatMessagePart[] = []; + const segments: ChatExternalRunSegmentProjection[] = []; + const createSegment = ( + segmentId: string, + providerSequence: number, + parts: readonly ChatMessagePart[], + precedingUserText?: string + ): ChatExternalRunSegmentProjection => ({ + message: { + attachments: [], + id: `external:${run.sessionKey}:${run.providerRunId}:segment:${segmentId}`, + parts, + providerRunId: run.providerRunId, + role: "assistant", + sequence: providerSequence, + sessionKey: run.sessionKey, + timestampMs: run.updatedAtMs, + }, + ...(precedingUserText === undefined ? {} : { precedingUserText }), + providerSequence, + segmentId, + }); + const replaceSegmentParts = ( + index: number, + parts: readonly ChatMessagePart[] + ): void => { + const previous = segments[index]; + if (previous === undefined) return; + segments[index] = { + ...previous, + message: { ...previous.message, parts }, + }; + }; + const upsertSinglePart = ( + segmentId: string, + providerSequence: number, + part: ChatMessagePart + ): void => { + const existingIndex = segments.findIndex( + (segment) => segment.segmentId === segmentId + ); + if (existingIndex === -1) { + segments.push(createSegment(segmentId, providerSequence, [part])); + return; + } + replaceSegmentParts(existingIndex, [part]); + }; if (run.parts !== undefined && run.parts.length > 0) { for (const part of run.parts) { switch (part.kind) { case "assistant": { - parts.push({ - kind: "text", - ...(part.segmentId === undefined - ? {} - : { - sourceKey: `${run.providerRunId}:${part.segmentId}`, - }), - ...(part.streamId === undefined - ? {} - : { - sourceStreamKey: `${run.providerRunId}:${part.streamId}`, - }), - text: part.text, - }); + upsertSinglePart( + `assistant:${part.segmentId ?? part.sequence}`, + part.sequence, + { + kind: "text", + ...(part.segmentId === undefined + ? {} + : { + sourceKey: `${run.providerRunId}:${part.segmentId}`, + }), + ...(part.streamId === undefined + ? {} + : { + sourceStreamKey: `${run.providerRunId}:${part.streamId}`, + }), + text: part.text, + } + ); break; } case "thinking": { - parts.push({ - kind: "thinking", - ...(part.segmentId === undefined - ? {} - : { - sourceKey: `${run.providerRunId}:${part.segmentId}`, - }), - ...(part.streamId === undefined - ? {} - : { - sourceStreamKey: `${run.providerRunId}:${part.streamId}`, - }), - status: "running", - text: part.text, - }); + upsertSinglePart( + `thinking:${part.segmentId ?? part.sequence}`, + part.sequence, + { + kind: "thinking", + ...(part.segmentId === undefined + ? {} + : { + sourceKey: `${run.providerRunId}:${part.segmentId}`, + }), + ...(part.streamId === undefined + ? {} + : { + sourceStreamKey: `${run.providerRunId}:${part.streamId}`, + }), + status: "running", + text: part.text, + } + ); break; } case "tool": { - appendChatMessagePart(parts, { + const projectedTool: ChatToolPart = { callId: part.callId, ...(part.callIdSource === undefined ? {} @@ -586,84 +640,188 @@ export function projectChatExternalRun(run: ChatExternalRun): ChatExternalRunPro : { nameSource: part.nameSource }), ...(part.output === undefined ? {} : { output: part.output }), status: toolPartStatus(part.phase), - }); + }; + const matchingSyntheticIndex = segments.findIndex((segment) => + segment.message.parts.some( + (candidate) => + candidate.kind === "tool" && + chatToolResultMatchesCall(candidate, projectedTool) + ) + ); + const directSegmentId = `tool:${part.callId}`; + const directIndex = segments.findIndex( + (segment) => segment.segmentId === directSegmentId + ); + const existingIndex = + directIndex === -1 ? matchingSyntheticIndex : directIndex; + const segmentId = + existingIndex === -1 + ? directSegmentId + : (segments[existingIndex]?.segmentId ?? directSegmentId); + const toolParts = + existingIndex === -1 + ? [] + : [...(segments[existingIndex]?.message.parts ?? [])]; + appendChatMessagePart(toolParts, projectedTool); + if (existingIndex === -1) { + segments.push(createSegment(segmentId, part.sequence, toolParts)); + } else { + replaceSegmentParts(existingIndex, toolParts); + } break; } case "item": { + if ( + part.type === "compaction" && + (part.text === "Compacting context" || + part.text === "Context compacted") + ) { + upsertSinglePart(`compaction:${part.id}`, part.sequence, { + activity: + part.text === "Compacting context" + ? "running" + : "complete", + kind: "control", + text: part.text, + tone: "muted", + }); + const compactionIndex = segments.findIndex( + (segment) => segment.segmentId === `compaction:${part.id}` + ); + const compaction = segments[compactionIndex]; + if (compaction !== undefined && part.occurredAtMs !== undefined) { + segments[compactionIndex] = { + ...compaction, + message: { + ...compaction.message, + timestampMs: part.occurredAtMs, + }, + }; + } + } break; } case "user": { + segments.push( + createSegment( + `user:${part.sequence}`, + part.sequence, + [], + part.text + ) + ); break; } } } } - const assistantIndexes = parts.flatMap((part, index) => - part.kind === "text" ? [index] : [] + const assistantSegmentIndexes = segments.flatMap((segment, index) => + segment.message.parts.some((part) => part.kind === "text") ? [index] : [] ); - const renderedAssistantText = assistantIndexes + const renderedAssistantText = assistantSegmentIndexes .map((index) => { - const part = parts[index]; - return part?.kind === "text" ? part.text : ""; + const segment = segments[index]; + return segment?.message.parts + .filter((part) => part.kind === "text") + .map((part) => (part.kind === "text" ? part.text : "")) + .join(""); }) .join(""); if (run.text !== "" && renderedAssistantText !== run.text) { - const lastAssistantIndex = assistantIndexes.at(-1); + const lastAssistantIndex = assistantSegmentIndexes.at(-1); if ( lastAssistantIndex !== undefined && run.text.startsWith(renderedAssistantText) ) { - const previous = parts[lastAssistantIndex]; - if (previous?.kind === "text") { - parts[lastAssistantIndex] = { - ...previous, - text: previous.text + run.text.slice(renderedAssistantText.length), - }; + const previous = segments[lastAssistantIndex]; + if (previous !== undefined) { + const previousPartIndex = previous.message.parts.findLastIndex( + (part) => part.kind === "text" + ); + const previousPart = previous.message.parts[previousPartIndex]; + if (previousPart?.kind === "text") { + replaceSegmentParts( + lastAssistantIndex, + previous.message.parts.map((part, index) => + index === previousPartIndex + ? { + ...previousPart, + text: + previousPart.text + + run.text.slice(renderedAssistantText.length), + } + : part + ) + ); + } } } else if (lastAssistantIndex === undefined) { - parts.push({ - kind: "text", - sourceKey: `${run.providerRunId}:aggregate:assistant`, - sourceStreamKey: `${run.providerRunId}:assistant`, - text: run.text, - }); + segments.push( + createSegment("aggregate:assistant", Number.MAX_SAFE_INTEGER - 4, [ + { + kind: "text", + sourceKey: `${run.providerRunId}:aggregate:assistant`, + sourceStreamKey: `${run.providerRunId}:assistant`, + text: run.text, + }, + ]) + ); } else { - const insertionIndex = parts - .slice(0, lastAssistantIndex) - .filter((part) => part.kind !== "text").length; - const retained: ChatMessagePart[] = parts.filter( - (part) => part.kind !== "text" + const insertionIndex = assistantSegmentIndexes[0] ?? segments.length; + const aggregateProviderSequence = + segments[insertionIndex]?.providerSequence ?? Number.MAX_SAFE_INTEGER - 4; + const retained = segments.filter( + (segment) => !segment.message.parts.some((part) => part.kind === "text") + ); + retained.splice( + Math.min(insertionIndex, retained.length), + 0, + createSegment("aggregate:assistant", aggregateProviderSequence, [ + { + kind: "text", + sourceKey: `${run.providerRunId}:aggregate:assistant`, + sourceStreamKey: `${run.providerRunId}:assistant`, + text: run.text, + }, + ]) ); - retained.splice(insertionIndex, 0, { - kind: "text", - sourceKey: `${run.providerRunId}:aggregate:assistant`, - sourceStreamKey: `${run.providerRunId}:assistant`, - text: run.text, - }); - parts.splice(0, parts.length, ...retained); + segments.splice(0, segments.length, ...retained); } } if (run.projectionTruncated) { - parts.push({ - kind: "control", - text: "Some OpenClaw activity details were not returned.", - tone: "warning", - }); + segments.push( + createSegment("notice:truncated", Number.MAX_SAFE_INTEGER - 3, [ + { + kind: "control", + text: "Some OpenClaw activity details were not returned.", + tone: "warning", + }, + ]) + ); } if (run.continuity === "interrupted") { - parts.push({ - kind: "control", - text: "Activity updates were interrupted, so some details may be missing.", - tone: "warning", - }); + segments.push( + createSegment("notice:interrupted", Number.MAX_SAFE_INTEGER - 2, [ + { + kind: "control", + text: "Activity updates were interrupted, so some details may be missing.", + tone: "warning", + }, + ]) + ); } if (run.hasUnprojectedActivity) { - parts.push({ - kind: "control", - text: "Some additional OpenClaw activity could not be shown.", - tone: "warning", - }); + segments.push( + createSegment("notice:unprojected", Number.MAX_SAFE_INTEGER - 1, [ + { + kind: "control", + text: "Some additional OpenClaw activity could not be shown.", + tone: "warning", + }, + ]) + ); } + const parts = segments.flatMap((segment) => segment.message.parts); return { ...(run.abortBoundary === undefined ? {} : { abortBoundary: run.abortBoundary }), continuity: run.continuity, @@ -701,6 +859,7 @@ export function projectChatExternalRun(run: ChatExternalRun): ChatExternalRunPro }), projectionTruncated: run.projectionTruncated, providerRunId: run.providerRunId, + segments, source: run.source, ...(run.streamResets === undefined ? {} diff --git a/greenfield/src/browser/chat/chatMessageOrdering.ts b/greenfield/src/browser/chat/chatMessageOrdering.ts new file mode 100644 index 000000000..adf4ab271 --- /dev/null +++ b/greenfield/src/browser/chat/chatMessageOrdering.ts @@ -0,0 +1,59 @@ +import type { ChatDisplayMessage } from "./chatTypes.ts"; + +function compareExactStrings(left: string, right: string): number { + if (left === right) return 0; + return left < right ? -1 : 1; +} + +function chronologicalMessageOrder( + left: ChatDisplayMessage, + right: ChatDisplayMessage +): number { + const leftUsesFallback = left.timestampMs === undefined; + const rightUsesFallback = right.timestampMs === undefined; + if (leftUsesFallback !== rightUsesFallback) return leftUsesFallback ? 1 : -1; + return ( + (left.timestampMs ?? 0) - (right.timestampMs ?? 0) || + left.sequence - right.sequence || + compareExactStrings(left.id, right.id) + ); +} + +function messageOrderGroup(message: ChatDisplayMessage): string { + return message.providerRunId === undefined + ? `message:${message.id}` + : `provider-run:${message.providerRunId}`; +} + +/** + * Sorts provider-run rows as one stable causal group among ordinary messages. + * @param messages Runtime or merged transcript rows. + * @returns A deterministic chronological copy with provider sequence preserved per run. + */ +export function sortChatDisplayMessages( + messages: readonly ChatDisplayMessage[] +): readonly ChatDisplayMessage[] { + const groupOrder = new Map(); + for (const message of messages) { + const group = messageOrderGroup(message); + const previous = groupOrder.get(group); + if (previous === undefined || chronologicalMessageOrder(message, previous) < 0) { + groupOrder.set(group, message); + } + } + return [...messages].toSorted((left, right) => { + const leftGroup = messageOrderGroup(left); + const rightGroup = messageOrderGroup(right); + if (leftGroup === rightGroup && left.providerRunId !== undefined) { + return ( + left.sequence - right.sequence || compareExactStrings(left.id, right.id) + ); + } + const leftOrder = groupOrder.get(leftGroup) ?? left; + const rightOrder = groupOrder.get(rightGroup) ?? right; + return ( + chronologicalMessageOrder(leftOrder, rightOrder) || + compareExactStrings(leftGroup, rightGroup) + ); + }); +} diff --git a/greenfield/src/browser/chat/chatRuntimeStore.test.ts b/greenfield/src/browser/chat/chatRuntimeStore.test.ts index 211270f6c..f882a80b9 100644 --- a/greenfield/src/browser/chat/chatRuntimeStore.test.ts +++ b/greenfield/src/browser/chat/chatRuntimeStore.test.ts @@ -648,6 +648,10 @@ describe("chat runtime store", () => { store.state.sessions[sessionKey]?.externalRuns["provider-1"]?.message.parts ).toEqual([]); + store.installExternalRuns(sessionKey, []); + expect( + store.state.sessions[sessionKey]?.externalRuns["provider-1"]?.omissionCount + ).toBe(1); store.installExternalRuns(sessionKey, []); expect(chatRuntimeMessages(store.state, sessionKey)).toEqual([]); expect(chatRuntimePlans(store.state, sessionKey)).toEqual([]); @@ -1435,4 +1439,194 @@ describe("chat runtime store", () => { "without-timestamp", ]); }); + + test("keeps same-run activity around a steer and folds the later tool result", () => { + const store = createChatRuntimeStore(); + const providerRunId = "provider-steer"; + const initial = projectChatExternalRun({ + continuity: "complete", + hasUnprojectedActivity: false, + observationEpoch: 1, + observedAtMs: occurredAtMs, + parts: [ + { + kind: "thinking", + segmentId: "reasoning-before", + sequence: 1, + streamId: "agent:reasoning", + text: "Before steer", + }, + { + callId: "call-before", + input: '{"cmd":"bun test"}', + isError: false, + kind: "tool", + name: "bash", + phase: "started", + sequence: 2, + }, + ], + projectionTruncated: false, + providerRunId, + sessionKey, + source: "provider-runtime", + text: "", + updatedAtMs: occurredAtMs, + }); + store.installExternalRuns(sessionKey, [initial]); + store.enqueue({ + attachments: [], + clientRunId: "client-steer", + createdAtMs: occurredAtMs + 1, + delivery: "accepted", + idempotencyKey: "steer-idempotency", + sessionKey, + text: "Continue", + }); + store.installExternalRuns(sessionKey, [ + projectChatExternalRun({ + continuity: "complete", + hasUnprojectedActivity: false, + observationEpoch: 2, + observedAtMs: occurredAtMs, + parts: [ + { + kind: "thinking", + segmentId: "reasoning-before", + sequence: 1, + streamId: "agent:reasoning", + text: "Before steer", + }, + { + callId: "call-before", + input: '{"cmd":"bun test"}', + isError: false, + kind: "tool", + name: "bash", + phase: "started", + sequence: 2, + }, + { kind: "user", sequence: 3, text: "Continue" }, + { + callId: "call-before", + isError: false, + kind: "tool", + name: "bash", + output: "passed", + phase: "succeeded", + sequence: 4, + }, + { + kind: "thinking", + segmentId: "reasoning-after", + sequence: 5, + streamId: "agent:reasoning", + text: "After steer", + }, + ], + projectionTruncated: false, + providerRunId, + sessionKey, + source: "provider-runtime", + text: "", + updatedAtMs: occurredAtMs + 2, + }), + ]); + + const messages = chatRuntimeMessages(store.state, sessionKey); + expect(messages.map((message) => message.role)).toEqual([ + "assistant", + "assistant", + "user", + "assistant", + ]); + expect(messages.flatMap((message) => message.parts)).toEqual([ + expect.objectContaining({ kind: "thinking", text: "Before steer" }), + expect.objectContaining({ + input: '{"cmd":"bun test"}', + kind: "tool", + output: "passed", + status: "completed", + }), + { kind: "text", text: "Continue" }, + expect.objectContaining({ kind: "thinking", text: "After steer" }), + ]); + }); + + test("retires an omitted external run only after one authoritative grace poll", () => { + const store = createChatRuntimeStore(); + const projection = projectChatExternalRun({ + continuity: "complete", + hasUnprojectedActivity: false, + observationEpoch: 1, + observedAtMs: occurredAtMs, + parts: [ + { + kind: "thinking", + sequence: 1, + text: "Still active", + }, + ], + projectionTruncated: false, + providerRunId: "provider-terminal-lag", + sessionKey, + source: "provider-runtime", + text: "", + updatedAtMs: occurredAtMs, + }); + store.installExternalRuns(sessionKey, [projection]); + + store.installExternalRuns(sessionKey, []); + expect( + store.state.sessions[sessionKey]?.externalRuns["provider-terminal-lag"] + ?.omissionCount + ).toBe(1); + expect(chatRuntimeMessages(store.state, sessionKey)).not.toHaveLength(0); + + store.installExternalRuns(sessionKey, []); + expect( + store.state.sessions[sessionKey]?.externalRuns["provider-terminal-lag"] + ).toBeUndefined(); + expect(chatRuntimeMessages(store.state, sessionKey)).toEqual([]); + }); + + test("does not retire external activity from a canonical user echo", () => { + const store = createChatRuntimeStore(); + store.installExternalRuns(sessionKey, [ + projectChatExternalRun({ + continuity: "complete", + hasUnprojectedActivity: false, + observationEpoch: 1, + observedAtMs: occurredAtMs, + parts: [{ kind: "thinking", sequence: 1, text: "Retain me" }], + projectionTruncated: false, + providerRunId: "provider-user-echo", + sessionKey, + source: "provider-runtime", + text: "", + updatedAtMs: occurredAtMs, + }), + ]); + store.reconcileHistory(sessionKey, { + clientRunIds: [], + idempotencyKeys: ["user-echo"], + providerRunIds: [], + runIds: [], + throughCursor: 0, + }); + expect( + store.state.sessions[sessionKey]?.externalRuns["provider-user-echo"] + ).toBeDefined(); + + store.reconcileHistory(sessionKey, { + clientRunIds: [], + idempotencyKeys: [], + providerRunIds: ["provider-user-echo"], + runIds: [], + throughCursor: 0, + }); + expect( + store.state.sessions[sessionKey]?.externalRuns["provider-user-echo"] + ).toBeUndefined(); + }); }); diff --git a/greenfield/src/browser/chat/chatRuntimeStore.ts b/greenfield/src/browser/chat/chatRuntimeStore.ts index df6867ff1..ddb8323d3 100644 --- a/greenfield/src/browser/chat/chatRuntimeStore.ts +++ b/greenfield/src/browser/chat/chatRuntimeStore.ts @@ -1,6 +1,7 @@ import { Store } from "@tanstack/react-store"; import { mergeChatStreamText } from "../../shared/chatStreamText.ts"; +import { sortChatDisplayMessages } from "./chatMessageOrdering.ts"; import type { ChatActivePlanView, ChatDisplayMessage, @@ -164,11 +165,15 @@ export interface ChatExternalRunProjection { readonly continuity: "complete" | "interrupted"; readonly hasUnprojectedActivity: boolean; readonly message: ChatDisplayMessage; + /** Consecutive authoritative inventories that omitted this still-retained run. */ + readonly omissionCount?: number; readonly observationEpoch: number; readonly observedAtMs: number; readonly plan?: ChatActivePlanView; readonly projectionTruncated: boolean; readonly providerRunId: string; + /** Provider-ordered assistant lanes split at steer/user boundaries. */ + readonly segments?: readonly ChatExternalRunSegmentProjection[]; readonly source: "provider-in-flight" | "provider-runtime"; readonly streamResets?: readonly Readonly<{ readonly resetKey: string; @@ -177,6 +182,14 @@ export interface ChatExternalRunProjection { readonly updatedAtMs: number; } +export interface ChatExternalRunSegmentProjection { + readonly message: ChatDisplayMessage; + /** Provider user text preceding this lane; used only to place an existing user row. */ + readonly precedingUserText?: string; + readonly providerSequence: number; + readonly segmentId: string; +} + function emptySession(): ChatSessionRuntime { return { eventIdentities: [], @@ -303,6 +316,9 @@ function planAfterEvent( function truncatedExternalPartIdentity(part: ChatMessagePart): string | undefined { if (part.kind === "tool") return `tool:${part.callId}`; + if (part.kind === "control" && part.activity !== undefined) { + return "activity:compaction"; + } if ( (part.kind === "text" || part.kind === "thinking") && part.sourceKey !== undefined @@ -527,7 +543,7 @@ function mergeTruncatedExternalParts( } const merged: ChatMessagePart[] = knownParts.filter( - (part) => part.kind !== "control" + (part) => part.kind !== "control" || part.activity !== undefined ); const knownIdentities = new Set( merged.flatMap((part) => { @@ -548,7 +564,7 @@ function mergeTruncatedExternalParts( } let lastIncomingIndex: number | undefined; for (const [incomingIndex, part] of replayedIncoming.entries()) { - if (part.kind === "control") continue; + if (part.kind === "control" && part.activity === undefined) continue; const identity = truncatedExternalPartIdentity(part); const existingIndex = identity === undefined @@ -587,6 +603,7 @@ function mergeTruncatedExternalParts( for (const part of [...incomingParts, ...knownParts]) { if ( part.kind === "control" && + part.activity === undefined && !controls.some( (candidate) => candidate.kind === "control" && candidate.text === part.text @@ -598,6 +615,164 @@ function mergeTruncatedExternalParts( return [...merged, ...controls]; } +function mergeExternalSegments( + known: readonly ChatExternalRunSegmentProjection[], + incoming: readonly ChatExternalRunSegmentProjection[], + replacedSourceStreams: ReadonlySet +): readonly ChatExternalRunSegmentProjection[] { + const merged: ChatExternalRunSegmentProjection[] = known + .map((segment) => { + const retainedParts = segment.message.parts.filter( + (part) => + (part.kind !== "text" && part.kind !== "thinking") || + part.sourceStreamKey === undefined || + !replacedSourceStreams.has(part.sourceStreamKey) + ); + return { + ...segment, + message: { ...segment.message, parts: retainedParts }, + }; + }) + .filter( + (segment) => + segment.precedingUserText !== undefined || + segment.message.parts.length > 0 + ); + let lastIncomingIndex: number | undefined; + for (const [incomingIndex, segment] of incoming.entries()) { + const existingIndex = merged.findIndex( + (candidate) => candidate.segmentId === segment.segmentId + ); + if (existingIndex !== -1) { + const previous = merged[existingIndex]!; + const parts = mergeTruncatedExternalParts( + previous.message.parts, + segment.message.parts, + replacedSourceStreams + ); + const unchanged = + JSON.stringify(previous.message.parts) === JSON.stringify(parts); + const lifecycleObservation = parts.some( + (part) => part.kind === "control" && part.activity !== undefined + ); + merged[existingIndex] = { + ...previous, + ...segment, + message: { + ...segment.message, + parts, + ...(unchanged && + !lifecycleObservation && + previous.message.timestampMs !== undefined + ? { timestampMs: previous.message.timestampMs } + : {}), + }, + }; + lastIncomingIndex = existingIndex; + continue; + } + const nextKnownId = incoming + .slice(incomingIndex + 1) + .map((candidate) => candidate.segmentId) + .find((segmentId) => + merged.some((candidate) => candidate.segmentId === segmentId) + ); + const nextKnownIndex = + nextKnownId === undefined + ? -1 + : merged.findIndex((candidate) => candidate.segmentId === nextKnownId); + let insertionIndex = nextKnownIndex === -1 ? merged.length : nextKnownIndex; + if (lastIncomingIndex !== undefined) { + insertionIndex = Math.min( + lastIncomingIndex + 1, + nextKnownIndex === -1 ? Infinity : nextKnownIndex + ); + } + merged.splice(insertionIndex, 0, segment); + lastIncomingIndex = insertionIndex; + } + return merged; +} + +function externalSegments( + projection: ChatExternalRunProjection +): readonly ChatExternalRunSegmentProjection[] { + return ( + projection.segments ?? [ + { + message: projection.message, + providerSequence: projection.message.sequence, + segmentId: "legacy-aggregate", + }, + ] + ); +} + +function withExternalSegments( + projection: ChatExternalRunProjection, + segments: readonly ChatExternalRunSegmentProjection[] +): ChatExternalRunProjection { + const message = { + ...projection.message, + parts: segments.flatMap((segment) => segment.message.parts), + }; + if ( + projection.segments === undefined && + segments.length === 1 && + segments[0]?.segmentId === "legacy-aggregate" && + segments[0].precedingUserText === undefined + ) { + return { ...projection, message }; + } + return { + ...projection, + message, + segments, + }; +} + +function reconcileExternalSegmentsWithAuthoritativeParts( + segments: readonly ChatExternalRunSegmentProjection[], + parts: readonly ChatMessagePart[] +): readonly ChatExternalRunSegmentProjection[] { + const authoritativeByIdentity = new Map( + parts.flatMap((part) => { + const identity = truncatedExternalPartIdentity(part); + return identity === undefined ? [] : [[identity, part] as const]; + }) + ); + return segments.flatMap((segment) => { + if (segment.message.parts.length === 0) return [segment]; + const reconciled = segment.message.parts.flatMap((part) => { + const identity = truncatedExternalPartIdentity(part); + if (identity === undefined) return [part]; + const authoritative = authoritativeByIdentity.get(identity); + return authoritative === undefined ? [] : [authoritative]; + }); + return reconciled.length === 0 + ? [] + : [ + { + ...segment, + message: { ...segment.message, parts: reconciled }, + }, + ]; + }); +} + +function trimExternalRuns( + runs: Readonly> +): Readonly> { + return Object.fromEntries( + Object.entries(runs) + .toSorted( + ([leftId, left], [rightId, right]) => + right.updatedAtMs - left.updatedAtMs || leftId.localeCompare(rightId) + ) + .slice(0, retainedSettledRunLimit) + ); +} + function applyRunEvent(run: ChatRuntimeRun, event: ChatRuntimeEvent): ChatRuntimeRun { let parts = run.message.parts; let phase = run.phase; @@ -1008,6 +1183,7 @@ export class ChatRuntimeStore extends Store { projection: Readonly<{ clientRunIds: readonly string[]; idempotencyKeys: readonly string[]; + providerRunIds?: readonly string[]; runIds: readonly string[]; throughCursor: number; }> @@ -1028,6 +1204,10 @@ export class ChatRuntimeStore extends Store { for (const runId of projection.runIds) { delete runs[runId]; } + const externalRuns = { ...session.externalRuns }; + for (const providerRunId of projection.providerRunIds ?? []) { + delete externalRuns[providerRunId]; + } return { ...state, sessions: { @@ -1038,6 +1218,7 @@ export class ChatRuntimeStore extends Store { projection.throughCursor < session.lastCursor || runsNeedReconciliation(runs), optimisticSends, + externalRuns, runs, }, }, @@ -1168,10 +1349,10 @@ export class ChatRuntimeStore extends Store { ): void { this.setState((state) => { const session = state.sessions[sessionKey] ?? emptySession(); - const externalRuns = Object.fromEntries( + const installed = Object.fromEntries( projections.map((projection) => { const existing = session.externalRuns[projection.providerRunId]; - if (!projection.projectionTruncated || existing === undefined) { + if (existing === undefined || !projection.projectionTruncated) { return [projection.providerRunId, projection] as const; } const existingResetByStream = new Map( @@ -1189,23 +1370,49 @@ export class ChatRuntimeStore extends Store { ) .map(({ sourceStreamKey }) => sourceStreamKey) ); - const parts = mergeTruncatedExternalParts( + const segments = mergeExternalSegments( + externalSegments(existing), + externalSegments(projection), + newlyReplacedStreams + ); + const authoritativeParts = mergeTruncatedExternalParts( existing.message.parts, projection.message.parts, newlyReplacedStreams ); + const reconciledSegments = + reconcileExternalSegmentsWithAuthoritativeParts( + segments, + authoritativeParts + ); const streamResets = projection.streamResets ?? existing.streamResets; - const preserved: ChatExternalRunProjection = { - ...projection, - message: { ...projection.message, parts }, - ...((projection.plan ?? existing.plan) === undefined - ? {} - : { plan: projection.plan ?? existing.plan }), - ...(streamResets === undefined ? {} : { streamResets }), - }; + const plan = projection.plan ?? existing.plan; + const preserved = withExternalSegments( + { + ...projection, + omissionCount: 0, + ...(plan === undefined ? {} : { plan }), + ...(streamResets === undefined ? {} : { streamResets }), + }, + reconciledSegments + ); return [projection.providerRunId, preserved] as const; }) ); + const omitted = Object.fromEntries( + Object.entries(session.externalRuns).flatMap(([providerRunId, run]) => { + if (installed[providerRunId] !== undefined) return []; + if (truncated) return [[providerRunId, run] as const]; + const omissionCount = (run.omissionCount ?? 0) + 1; + return omissionCount > 1 + ? [] + : [[providerRunId, { ...run, omissionCount }] as const]; + }) + ); + const externalRuns = trimExternalRuns({ + ...omitted, + ...installed, + }); return { ...state, sessions: { @@ -1247,7 +1454,7 @@ export function chatRuntimeMessages( const session = state.sessions[sessionKey]; if (session === undefined) return []; const optimisticSends = Object.values(session.optimisticSends); - const optimistic = optimisticSends.map((send): ChatDisplayMessage => ({ + const optimisticMessage = (send: ChatOptimisticSend): ChatDisplayMessage => ({ attachments: send.attachments.map((attachment) => ({ id: attachment.id, mediaType: attachment.mediaType, @@ -1265,7 +1472,7 @@ export function chatRuntimeMessages( sequence: Number.MAX_SAFE_INTEGER - 1, sessionKey, timestampMs: send.createdAtMs, - })); + }); const optimisticRunIds = new Set(optimisticSends.map((send) => send.clientRunId)); const runs = Object.values(session.runs).flatMap((run) => [ ...(run.userMessage === undefined || optimisticRunIds.has(run.message.runId ?? "") @@ -1275,9 +1482,52 @@ export function chatRuntimeMessages( ? [] : [run.message]), ]); - const externalRuns = Object.values(session.externalRuns).map( - ({ message }) => message - ); + const matchedOptimisticRunIds = new Set(); + const externalRows = Object.values(session.externalRuns) + .toSorted( + (left, right) => + left.observedAtMs - right.observedAtMs || + left.providerRunId.localeCompare(right.providerRunId) + ) + .flatMap((run) => { + const messages: ChatDisplayMessage[] = []; + let pendingUserText: string | undefined; + for (const segment of externalSegments(run)) { + if (segment.precedingUserText !== undefined) { + pendingUserText = segment.precedingUserText; + const matchingSend = optimisticSends.find( + (send) => + !matchedOptimisticRunIds.has(send.clientRunId) && + send.text === pendingUserText + ); + if (matchingSend !== undefined) { + matchedOptimisticRunIds.add(matchingSend.clientRunId); + messages.push({ + ...optimisticMessage(matchingSend), + providerRunId: run.providerRunId, + sequence: segment.providerSequence, + }); + } + } + if (segment.message.parts.length === 0) { + if (run.segments === undefined) { + messages.push(segment.message); + } + continue; + } + messages.push({ + ...segment.message, + ...(pendingUserText === undefined + ? {} + : { precedingUserTextAnchor: pendingUserText }), + }); + pendingUserText = undefined; + } + return messages; + }); + const optimistic = optimisticSends + .filter((send) => !matchedOptimisticRunIds.has(send.clientRunId)) + .map((send) => optimisticMessage(send)); const externalTruncation: readonly ChatDisplayMessage[] = session.externalRunsTruncated ? [ @@ -1297,20 +1547,12 @@ export function chatRuntimeMessages( }, ] : []; - return [...optimistic, ...runs, ...externalRuns, ...externalTruncation].toSorted( - (left, right) => { - const leftUsesFallback = left.timestampMs === undefined; - const rightUsesFallback = right.timestampMs === undefined; - if (leftUsesFallback !== rightUsesFallback) { - return leftUsesFallback ? 1 : -1; - } - return ( - (left.timestampMs ?? 0) - (right.timestampMs ?? 0) || - left.sequence - right.sequence || - left.id.localeCompare(right.id) - ); - } - ); + return sortChatDisplayMessages([ + ...optimistic, + ...runs, + ...externalRows, + ...externalTruncation, + ]); } /** diff --git a/greenfield/src/browser/chat/chatToolPresentation.ts b/greenfield/src/browser/chat/chatToolPresentation.ts new file mode 100644 index 000000000..2b09abd41 --- /dev/null +++ b/greenfield/src/browser/chat/chatToolPresentation.ts @@ -0,0 +1,85 @@ +import type { ChatToolPart } from "./chatTypes.ts"; + +const toolSummaryMaximumCodePoints = 120; + +function boundedToolSummaryText(value: string): string | undefined { + const normalized = value + .replaceAll(/[\p{Cc}\p{Cf}]/gu, " ") + .replaceAll(/\s+/gu, " ") + .trim(); + if (normalized === "") return undefined; + // oxlint-disable-next-line unicorn/prefer-spread -- The 120-unit contract counts Unicode code points, not UTF-16 units or grapheme clusters. + const codePoints = Array.from(normalized); + if (codePoints.length <= toolSummaryMaximumCodePoints) return normalized; + return `${codePoints.slice(0, toolSummaryMaximumCodePoints - 1).join("")}…`; +} + +/** + * Returns a provider-neutral display name for one tool. + * @param name Provider tool name. + * @returns Human-readable bounded label. + */ +export function toolDisplayName(name: string): string { + const unqualified = name.startsWith("functions.") + ? name.slice("functions.".length) + : name; + const normalized = ["bash", "exec", "exec_command"].includes(unqualified) + ? "bash" + : unqualified; + const words = boundedToolSummaryText(normalized.replaceAll(/[_-]/gu, " ")); + if (words === undefined) return "Tool"; + return ( + boundedToolSummaryText(`${words.charAt(0).toUpperCase()}${words.slice(1)}`) ?? + "Tool" + ); +} + +/** + * Returns a bounded summary from only allowlisted provider-visible input fields. + * @param part Tool lifecycle part. + * @returns Command/path summary, or undefined when no trusted summary exists. + */ +export function toolDescription(part: ChatToolPart): string | undefined { + let candidate = part.input; + if (typeof candidate === "string") { + try { + candidate = JSON.parse(candidate) as unknown; + } catch { + return undefined; + } + } + if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) { + return undefined; + } + const input = candidate as Readonly>; + let command: string | undefined; + if (typeof input.command === "string") command = input.command; + else if (typeof input.cmd === "string") command = input.cmd; + if (command !== undefined) { + let workingDirectory: string | undefined; + if (typeof input.workdir === "string") workingDirectory = input.workdir; + else if (typeof input.cwd === "string") workingDirectory = input.cwd; + const directoryName = workingDirectory?.split(/[\\/]/u).findLast(Boolean); + return boundedToolSummaryText( + directoryName === undefined ? command : `${command} (${directoryName})` + ); + } + return typeof input.path === "string" + ? boundedToolSummaryText(input.path) + : undefined; +} + +/** + * Returns a bounded provider-visible activity label without inspecting output or errors. + * @param part Tool lifecycle part. + * @returns Safe tool activity text. + */ +export function chatToolActivityText(part: ChatToolPart): string { + const label = toolDisplayName(part.name); + const description = toolDescription(part); + return ( + boundedToolSummaryText( + description === undefined ? label : `${label}: ${description}` + ) ?? label + ); +} diff --git a/greenfield/src/browser/chat/chatTranscriptProjection.ts b/greenfield/src/browser/chat/chatTranscriptProjection.ts new file mode 100644 index 000000000..398458a9f --- /dev/null +++ b/greenfield/src/browser/chat/chatTranscriptProjection.ts @@ -0,0 +1,132 @@ +import { chatToolActivityText } from "./chatToolPresentation.ts"; +import type { ChatDisplayMessage } from "./chatTypes.ts"; + +export const activeCompactionMaximumAgeMs = 5 * 60_000; +export const completedCompactionMaximumAgeMs = 5000; + +function messageMatchesActiveRun( + message: ChatDisplayMessage, + activeRunId: string +): boolean { + return ( + message.runId === activeRunId || + message.clientRunId === activeRunId || + message.providerRunId === activeRunId + ); +} + +function retainedCompactionMessages( + messages: readonly ChatDisplayMessage[], + nowMs: number +): readonly ChatDisplayMessage[] { + return messages.filter((message) => { + const compaction = message.parts.find( + (part) => part.kind === "control" && part.activity !== undefined + ); + if (compaction?.kind !== "control" || compaction.activity === undefined) { + return true; + } + if (message.timestampMs === undefined) return false; + const maximumAge = + compaction.activity === "running" + ? activeCompactionMaximumAgeMs + : completedCompactionMaximumAgeMs; + return nowMs - message.timestampMs < maximumAge; + }); +} + +function withActiveChatActivity( + messages: readonly ChatDisplayMessage[], + activeRunIds: readonly string[], + sessionKey: string +): readonly ChatDisplayMessage[] { + const activityAfterMessage = new Map(); + const trailingActivity: ChatDisplayMessage[] = []; + const emittedTargets = new Set(); + for (const activeRunId of activeRunIds) { + const candidates = messages.filter((message) => + messageMatchesActiveRun(message, activeRunId) + ); + const target = candidates.at(-1); + const latestUserIndex = candidates.findLastIndex( + (message) => message.role === "user" + ); + const assistantCandidates = candidates + .slice(latestUserIndex + 1) + .filter((message) => message.role === "assistant"); + const activitySource = assistantCandidates.at(-1); + if ( + (target !== undefined && emittedTargets.has(target.id)) || + candidates.some((message) => + message.parts.some( + (part) => part.kind === "control" && part.activity === "running" + ) + ) || + activitySource?.parts.some( + (part) => part.kind === "text" && part.text.trim() !== "" + ) + ) { + continue; + } + if (target !== undefined) emittedTargets.add(target.id); + const latestPart = activitySource?.parts.at(-1); + const text = + latestPart?.kind === "tool" ? chatToolActivityText(latestPart) : "Thinking…"; + const identity = + latestPart?.kind === "tool" ? `tool:${latestPart.callId}` : "thinking"; + const activity: ChatDisplayMessage = { + attachments: [], + id: `activity:${activeRunId}:${identity}`, + parts: [ + { + activity: "running", + kind: "control", + text, + tone: "muted", + }, + ], + role: "assistant", + sequence: target?.sequence ?? Number.MAX_SAFE_INTEGER, + sessionKey: target?.sessionKey ?? sessionKey, + ...(target?.timestampMs === undefined + ? {} + : { timestampMs: target.timestampMs }), + }; + if (target === undefined) { + trailingActivity.push(activity); + } else { + activityAfterMessage.set(target.id, [ + ...(activityAfterMessage.get(target.id) ?? []), + activity, + ]); + } + } + return [ + ...messages.flatMap((message) => [ + message, + ...(activityAfterMessage.get(message.id) ?? []), + ]), + ...trailingActivity, + ]; +} + +/** + * Projects bounded compaction feedback and one live activity row per active run. + * @param messages Canonical and provider-runtime transcript rows. + * @param activeRunIds Authoritative currently active run identities. + * @param sessionKey Current chat session identity. + * @param nowMs Clock used only for the bounded compaction lifecycle. + * @returns Rows in the exact order consumed by visibility filtering and virtualization. + */ +export function projectChatTranscriptMessages( + messages: readonly ChatDisplayMessage[], + activeRunIds: readonly string[], + sessionKey: string, + nowMs: number +): readonly ChatDisplayMessage[] { + return withActiveChatActivity( + retainedCompactionMessages(messages, nowMs), + activeRunIds, + sessionKey + ); +} diff --git a/greenfield/src/browser/chat/chatTypes.ts b/greenfield/src/browser/chat/chatTypes.ts index ff5384e7d..1baa6b207 100644 --- a/greenfield/src/browser/chat/chatTypes.ts +++ b/greenfield/src/browser/chat/chatTypes.ts @@ -91,6 +91,7 @@ export interface ChatToolPart { /** Ordered provider status/control information retained beside run content. */ export interface ChatControlPart { + readonly activity?: "complete" | "running"; readonly kind: "control"; readonly text: string; readonly tone: "danger" | "muted" | "warning"; @@ -117,6 +118,8 @@ export interface ChatDisplayMessage { readonly idempotencyKey?: string; readonly hydration?: "error" | "loading" | "required"; readonly parts: readonly ChatMessagePart[]; + /** Provider user echo used only to place the matching canonical/local user row. */ + readonly precedingUserTextAnchor?: string; readonly providerRunId?: string; readonly role: "assistant" | "control" | "user"; readonly runId?: string; diff --git a/greenfield/src/browser/chat/chatViewProjection.test.ts b/greenfield/src/browser/chat/chatViewProjection.test.ts index 1e757d18c..f44eca5d8 100644 --- a/greenfield/src/browser/chat/chatViewProjection.test.ts +++ b/greenfield/src/browser/chat/chatViewProjection.test.ts @@ -6,6 +6,7 @@ import { type GatewaySession, type ListGatewaySessionsResult, } from "../../contracts/gatewaySessions.ts"; +import { sortChatDisplayMessages } from "./chatMessageOrdering.ts"; import { mergeChatMessages, projectChatHistory, @@ -937,4 +938,193 @@ describe("chat view projection", () => { mergeChatMessages(canonical, runtime, new Set()).map(({ id }) => id) ).toEqual(["message-1", "message-2", "runtime-1", "runtime-2"]); }); + + test("sorts provider groups transitively and independently of input order", () => { + const providerLaterSequence = { + attachments: [], + id: "provider-later-sequence", + parts: [], + providerRunId: "provider-group", + role: "assistant" as const, + sequence: 2, + sessionKey, + timestampMs: 100, + }; + const providerEarlierSequence = { + ...providerLaterSequence, + id: "provider-earlier-sequence", + sequence: 1, + timestampMs: 300, + }; + const ordinary = { + ...providerLaterSequence, + id: "ordinary", + providerRunId: undefined, + sequence: 3, + timestampMs: 200, + }; + const permutations = [ + [providerLaterSequence, providerEarlierSequence, ordinary], + [providerLaterSequence, ordinary, providerEarlierSequence], + [providerEarlierSequence, providerLaterSequence, ordinary], + [providerEarlierSequence, ordinary, providerLaterSequence], + [ordinary, providerLaterSequence, providerEarlierSequence], + [ordinary, providerEarlierSequence, providerLaterSequence], + ]; + + expect( + permutations.map((messages) => + sortChatDisplayMessages(messages).map(({ id }) => id) + ) + ).toEqual( + Array.from({ length: permutations.length }, () => [ + "provider-earlier-sequence", + "provider-later-sequence", + "ordinary", + ]) + ); + }); + + test("orders canonically equivalent distinct ids and run groups deterministically", () => { + const nfc = "é"; + const nfd = "e\u0301"; + const messageWith = (id: string, providerRunId: string) => ({ + attachments: [], + id, + parts: [], + providerRunId, + role: "assistant" as const, + sequence: 1, + sessionKey, + timestampMs: 100, + }); + const equivalentIds = [ + messageWith(`message-${nfc}`, "same-run"), + messageWith(`message-${nfd}`, "same-run"), + ]; + const equivalentGroups = [ + messageWith("shared-message", `run-${nfc}`), + messageWith("shared-message", `run-${nfd}`), + ]; + + for (const input of [equivalentIds, equivalentIds.toReversed()]) { + expect(sortChatDisplayMessages(input).map(({ id }) => id)).toEqual([ + `message-${nfd}`, + `message-${nfc}`, + ]); + } + for (const input of [equivalentGroups, equivalentGroups.toReversed()]) { + expect( + sortChatDisplayMessages(input).map(({ providerRunId }) => providerRunId) + ).toEqual([`run-${nfd}`, `run-${nfc}`]); + } + }); + + test("anchors repeated same-text steers to the exact provider run", () => { + const canonicalUser = (id: string, providerRunId: string, sequence: number) => ({ + attachments: [], + id, + parts: [{ kind: "text" as const, text: "Continue" }], + providerRunId, + role: "user" as const, + sequence, + sessionKey, + }); + const external = ( + id: string, + sequence: number, + precedingUserTextAnchor?: string + ) => ({ + attachments: [], + id, + parts: [{ kind: "thinking" as const, status: "running" as const, text: id }], + ...(precedingUserTextAnchor === undefined ? {} : { precedingUserTextAnchor }), + providerRunId: "provider-current", + role: "assistant" as const, + sequence, + sessionKey, + }); + const activityBeforeId = "external:provider-current:segment:activity-before"; + const activityAfterId = "external:provider-current:segment:activity-after"; + const merged = mergeChatMessages( + [ + canonicalUser("older-same-text", "provider-older", 1), + canonicalUser("current-steer", "provider-current", 3), + ], + [external(activityBeforeId, 2), external(activityAfterId, 4, "Continue")], + new Set() + ); + + expect(merged.map(({ id }) => id)).toEqual([ + "older-same-text", + activityBeforeId, + "current-steer", + activityAfterId, + ]); + }); + + test("retires every external segment only when canonical assistant coverage arrives", () => { + const runtime = [ + { + attachments: [], + id: "external:provider:segment:thinking", + parts: [ + { + kind: "thinking" as const, + status: "running" as const, + text: "Working", + }, + ], + providerRunId: "provider-covered", + role: "assistant" as const, + sequence: 1, + sessionKey, + }, + { + attachments: [], + id: "external:provider:segment:tool", + parts: [ + { + callId: "call-1", + kind: "tool" as const, + name: "bash", + status: "completed" as const, + }, + ], + providerRunId: "provider-covered", + role: "assistant" as const, + sequence: 2, + sessionKey, + }, + ]; + const canonicalUser = { + attachments: [], + id: "canonical-user", + parts: [{ kind: "text" as const, text: "Prompt" }], + providerRunId: "provider-covered", + role: "user" as const, + sequence: 1, + sessionKey, + }; + expect( + mergeChatMessages([canonicalUser], runtime, new Set()).map(({ id }) => id) + ).toContain("external:provider:segment:tool"); + + expect( + mergeChatMessages( + [ + canonicalUser, + { + ...canonicalUser, + id: "canonical-assistant", + parts: [{ kind: "text" as const, text: "Done" }], + role: "assistant" as const, + sequence: 2, + }, + ], + runtime, + new Set() + ).map(({ id }) => id) + ).toEqual(["canonical-user", "canonical-assistant"]); + }); }); diff --git a/greenfield/src/browser/chat/chatViewProjection.ts b/greenfield/src/browser/chat/chatViewProjection.ts index 84ac8bc10..0aa6812b6 100644 --- a/greenfield/src/browser/chat/chatViewProjection.ts +++ b/greenfield/src/browser/chat/chatViewProjection.ts @@ -15,6 +15,7 @@ import { mergeChatToolPart, projectChatContractMessage, } from "./chatContractAdapter.ts"; +import { sortChatDisplayMessages } from "./chatMessageOrdering.ts"; import type { ChatDisplayMessage, ChatMessageAttachment, @@ -274,6 +275,12 @@ export function projectChatHistory( return foldHistoryToolResults(projectedMessages); } +function chatDisplayMessageText(message: ChatDisplayMessage): string { + return message.parts + .flatMap((part) => (part.kind === "text" ? [part.text] : [])) + .join(""); +} + /** * Combines canonical history and ephemeral runtime rows without duplicate identities. * @param history Canonical provider transcript. @@ -288,17 +295,99 @@ export function mergeChatMessages( ): readonly ChatDisplayMessage[] { const canonicalIds = new Set(history.map(({ id }) => id)); const canonical = history.filter((message) => !hiddenMessageIds.has(message.id)); - const ephemeral = runtime - .filter( + const canonicalIdempotencyKeys = new Set( + canonical.flatMap((message) => + message.idempotencyKey === undefined ? [] : [message.idempotencyKey] + ) + ); + const canonicalAssistantProviderRunIds = new Set( + canonical.flatMap((message) => + message.role === "assistant" && message.providerRunId !== undefined + ? [message.providerRunId] + : [] + ) + ); + const ephemeral = sortChatDisplayMessages( + runtime.filter( (message) => - !canonicalIds.has(message.id) && !hiddenMessageIds.has(message.id) + !canonicalIds.has(message.id) && + !hiddenMessageIds.has(message.id) && + !( + message.role === "user" && + message.idempotencyKey !== undefined && + canonicalIdempotencyKeys.has(message.idempotencyKey) + ) && + !( + message.role === "assistant" && + message.providerRunId !== undefined && + canonicalAssistantProviderRunIds.has(message.providerRunId) + ) ) - .toSorted( - (left, right) => - (left.timestampMs ?? Number.MAX_SAFE_INTEGER) - - (right.timestampMs ?? Number.MAX_SAFE_INTEGER) || - left.sequence - right.sequence || - left.id.localeCompare(right.id) - ); - return [...canonical, ...ephemeral]; + ); + const externalGroups = Map.groupBy( + ephemeral.filter( + (message) => + message.role === "assistant" && + message.providerRunId !== undefined && + message.id.startsWith("external:") + ), + (message) => message.providerRunId as string + ); + const beforeCanonical = new Map(); + const afterCanonical = new Map(); + const anchoredExternalIds = new Set(); + const claimedCanonicalUsers = new Set(); + for (const [providerRunId, messages] of externalGroups) { + let chunkStart = 0; + let previousAnchorId: string | undefined; + for (const [index, message] of messages.entries()) { + const anchor = message.precedingUserTextAnchor; + if (anchor === undefined) continue; + const matchingUsers = canonical.filter( + (candidate) => + candidate.role === "user" && + !claimedCanonicalUsers.has(candidate.id) && + chatDisplayMessageText(candidate) === anchor + ); + const canonicalUser = + matchingUsers.find( + (candidate) => candidate.providerRunId === providerRunId + ) ?? + matchingUsers.find((candidate) => candidate.providerRunId === undefined); + if (canonicalUser === undefined) continue; + claimedCanonicalUsers.add(canonicalUser.id); + const chunk = messages.slice(chunkStart, index); + if (previousAnchorId === undefined) { + beforeCanonical.set(canonicalUser.id, [ + ...(beforeCanonical.get(canonicalUser.id) ?? []), + ...chunk, + ]); + } else { + afterCanonical.set(previousAnchorId, [ + ...(afterCanonical.get(previousAnchorId) ?? []), + ...chunk, + ]); + } + for (const item of chunk) anchoredExternalIds.add(item.id); + previousAnchorId = canonicalUser.id; + chunkStart = index; + } + if (previousAnchorId !== undefined) { + const tail = messages.slice(chunkStart); + afterCanonical.set(previousAnchorId, [ + ...(afterCanonical.get(previousAnchorId) ?? []), + ...tail, + ]); + for (const item of tail) anchoredExternalIds.add(item.id); + } + } + const mergedCanonical = canonical.flatMap((message) => [ + ...(beforeCanonical.get(message.id) ?? []), + message, + ...(afterCanonical.get(message.id) ?? []), + ]); + const unanchored = ephemeral.filter( + (message) => !anchoredExternalIds.has(message.id) + ); + return [...mergedCanonical, ...unanchored]; } diff --git a/greenfield/src/browser/chat/useChatRuntimeProjection.test.tsx b/greenfield/src/browser/chat/useChatRuntimeProjection.test.tsx index 407928ff1..e04fa0a87 100644 --- a/greenfield/src/browser/chat/useChatRuntimeProjection.test.tsx +++ b/greenfield/src/browser/chat/useChatRuntimeProjection.test.tsx @@ -277,7 +277,7 @@ describe("chat runtime projection", () => { } }); - test("authoritatively removes an external run on the next empty runtime read", async () => { + test("retains one empty runtime read before authoritative removal", async () => { const sessionKey = "agent:main:main"; const store = createChatRuntimeStore(); let invocation = 0; @@ -322,6 +322,17 @@ describe("chat runtime projection", () => { await waitFor(() => expect(chatRuntimeMessages(store.state, sessionKey)).toHaveLength(1) ); + await queryClient.invalidateQueries({ + exact: true, + queryKey: chatRuntimeQueryKey(sessionKey), + }); + await waitFor(() => expect(query).toHaveBeenCalledTimes(2)); + expect(chatRuntimeMessages(store.state, sessionKey)).toHaveLength(1); + expect( + store.state.sessions[sessionKey]?.externalRuns["provider-run-1"] + ?.omissionCount + ).toBe(1); + await queryClient.invalidateQueries({ exact: true, queryKey: chatRuntimeQueryKey(sessionKey), @@ -329,7 +340,7 @@ describe("chat runtime projection", () => { await waitFor(() => expect(chatRuntimeMessages(store.state, sessionKey)).toEqual([]) ); - expect(query).toHaveBeenCalledTimes(2); + expect(query).toHaveBeenCalledTimes(3); } finally { rendered.unmount(); queryClient.clear(); diff --git a/greenfield/src/browser/logs/LogMaintenancePanel.tsx b/greenfield/src/browser/logs/LogMaintenancePanel.tsx new file mode 100644 index 000000000..31907ff10 --- /dev/null +++ b/greenfield/src/browser/logs/LogMaintenancePanel.tsx @@ -0,0 +1,580 @@ +import { FlaskConical, RefreshCw, RotateCcw, ShieldCheck } from "lucide-react"; +import { type ReactNode, useState } from "react"; +import * as v from "valibot"; + +import type { JobRunSummary } from "../../contracts/jobModel.ts"; +import type { JobRunDetail } from "../../contracts/jobs.ts"; +import { + logMaintenanceJobResultSchema, + type LogMaintenanceExecutionSummary, + type LogMaintenancePolicyId, + type LogMaintenancePolicyStatus, + type LogMaintenanceStatusOutput, + type RequestLogMaintenanceOutput, +} from "../../contracts/logs.ts"; +import { jobRunStateBadgeVariant, jobRunStateLabel } from "../jobs/jobRunPresentation.ts"; +import { formatDashboardDateTime } from "../lib/formatDateTime.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Badge } from "../ui/Badge.tsx"; +import { Button } from "../ui/Button.tsx"; +import { Card } from "../ui/Card.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { LoadingState } from "../ui/LoadingState.tsx"; +import { Modal } from "../ui/Modal.tsx"; +import { Text } from "../ui/Text.tsx"; +import { logFailureMessage } from "./logPresentation.ts"; + +interface MaintenanceAction { + readonly dryRun: boolean; + readonly policyId: LogMaintenancePolicyId; +} + +export interface LogMaintenancePanelProps { + readonly maintenance?: LogMaintenanceStatusOutput; + readonly maintenanceError?: string; + readonly maintenanceLoading?: boolean; + readonly maintenanceRefreshing?: boolean; + readonly onRefresh: () => void; + readonly onRequestMaintenance: ( + policyId: LogMaintenancePolicyId, + dryRun: boolean + ) => Promise; + readonly requestedRun?: JobRunDetail; + readonly requestedRunError?: string; + readonly requestedRunInactiveConfirmed?: boolean; + readonly requestedRunLoading?: boolean; + readonly requestedRunRequest?: RequestLogMaintenanceOutput; +} + +function runTimestamp(run: JobRunSummary): Readonly<{ + label: "Finished" | "Queued" | "Started"; + timestampMs: number; +}> { + if (run.finishedAtMs !== undefined) { + return { label: "Finished", timestampMs: run.finishedAtMs }; + } + if (run.firstStartedAtMs !== undefined) { + return { label: "Started", timestampMs: run.firstStartedAtMs }; + } + return { label: "Queued", timestampMs: run.queuedAtMs }; +} + +function RunLifecycle({ run }: Readonly<{ readonly run: JobRunSummary }>) { + const timestamp = runTimestamp(run); + return ( + + + {jobRunStateLabel(run.state)} + + + + ); +} + +const summaryFields = [ + ["Checked", "checkedTargets"], + ["Rotated", "rotated"], + ["Compressed", "compressed"], + ["Deleted", "deleted"], + ["Skipped", "skipped"], + ["Missing", "missing"], + ["Errors", "error"], +] as const; + +function summaryValue( + summary: LogMaintenanceExecutionSummary, + field: (typeof summaryFields)[number][1] +): number { + return field === "checkedTargets" + ? summary.checkedTargets + : summary.actionCounts[field]; +} + +function MaintenanceSummary({ + label, + summary, +}: Readonly<{ + readonly label: string; + readonly summary: LogMaintenanceExecutionSummary; +}>) { + return ( +

+ {summaryFields.map(([fieldLabel, field]) => ( +
+
{fieldLabel}
+
+ {summaryValue(summary, field)} +
+
+ ))} +
+ ); +} + +const unverifiedMaintenanceResultMessage = + "The durable maintenance result could not be verified. Lifecycle status remains visible, but result details are hidden."; + +function policyResultWarning(policy: LogMaintenancePolicyStatus): string | undefined { + return policy.id === "docker-managed" && + policy.lastRun?.run.state === "succeeded" && + policy.lastRun.summary === undefined + ? unverifiedMaintenanceResultMessage + : undefined; +} + +function PolicyRunHistory({ + policy, +}: Readonly<{ readonly policy: LogMaintenancePolicyStatus }>) { + return ( +
+ {policy.activeRun === undefined ? null : ( + + + Active run + + + + {policy.activeRun.id} + + + )} + {policy.lastRun === undefined ? ( + + No terminal run recorded. + + ) : ( +
+

Last run

+ + {policy.lastRun.run.terminalMessage === undefined ? null : ( + + {policy.lastRun.run.terminalMessage} + + )} + {policy.lastRun.summary === undefined ? null : ( + + )} + +
+ )} +
+ ); +} + +interface RequestedMaintenanceResultProjection { + readonly summary?: LogMaintenanceExecutionSummary; + readonly warning?: string; +} + +function projectRequestedMaintenanceResult( + request: RequestLogMaintenanceOutput | undefined, + detail: JobRunDetail | undefined +): RequestedMaintenanceResultProjection { + if ( + request === undefined || + detail === undefined || + detail.run.id !== request.jobRunId + ) { + return {}; + } + if (detail.result === undefined) { + return request.policyId === "docker-managed" && detail.run.state === "succeeded" + ? { warning: unverifiedMaintenanceResultMessage } + : {}; + } + const parsed = v.safeParse(logMaintenanceJobResultSchema, detail.result); + if ( + !parsed.success || + parsed.output.policyId !== request.policyId || + parsed.output.dryRun !== request.dryRun + ) { + return { warning: unverifiedMaintenanceResultMessage }; + } + return { summary: parsed.output.summary }; +} + +function RequestedRunStatus({ + detail, + error, + loading, + request, +}: Readonly<{ + readonly detail?: JobRunDetail; + readonly error?: string; + readonly loading: boolean; + readonly request: RequestLogMaintenanceOutput; +}>) { + const result = projectRequestedMaintenanceResult(request, detail); + const title = request.dryRun ? "Requested dry run" : "Requested maintenance run"; + let content: ReactNode; + if (loading && detail === undefined) { + content = ( + + ); + } else if (detail === undefined) { + content = ( + + Waiting for durable job status. + + ); + } else { + content = ( +
+ + + + {detail.run.terminalMessage === undefined ? null : ( + + {detail.run.terminalMessage} + + )} + {result.summary === undefined ? null : ( + + )} + +
+ ); + } + return ( +
+ + {title} + + + {request.jobRunId} + + + {content} +
+ ); +} + +function maintenanceActionIsLocked({ + maintenance, + maintenanceError, + requestedRun, + requestedRunInactiveConfirmed, + requestedRunRequest, +}: LogMaintenancePanelProps): boolean { + const requestedRunMatches = + requestedRunRequest !== undefined && + requestedRun?.run.id === requestedRunRequest.jobRunId; + const requestedRunActive = + requestedRunMatches && + (requestedRun.run.state === "queued" || requestedRun.run.state === "running"); + const requestedRunTerminal = requestedRunMatches && !requestedRunActive; + const requestedRunPending = + requestedRunInactiveConfirmed !== true && + (requestedRunActive || + (requestedRunRequest !== undefined && !requestedRunTerminal)); + return ( + maintenanceError !== undefined || + requestedRunPending || + (maintenance?.policies.some(({ activeRun }) => activeRun !== undefined) ?? false) + ); +} + +interface LogMaintenancePanelContentProps extends LogMaintenancePanelProps { + readonly actionError?: string; + readonly actionStatus?: string; + readonly onActionErrorChange: (error: string | undefined) => void; + readonly onActionStatusChange: (status: string | undefined) => void; + readonly onRunningActionChange: (action: MaintenanceAction | undefined) => void; + readonly runningAction?: MaintenanceAction; +} + +function LogMaintenancePanelContent(properties: LogMaintenancePanelContentProps) { + const { + actionError, + actionStatus, + maintenance, + maintenanceError, + maintenanceLoading = false, + maintenanceRefreshing = false, + onActionErrorChange, + onActionStatusChange, + onRefresh, + onRequestMaintenance, + onRunningActionChange, + requestedRun, + requestedRunError, + requestedRunLoading = false, + requestedRunRequest, + runningAction, + } = properties; + const [confirmedAction, setConfirmedAction] = useState(); + const externalActionLock = maintenanceActionIsLocked(properties); + const confirmedPolicy = maintenance?.policies.find( + ({ id }) => id === confirmedAction?.policyId + ); + const confirmationOpen = + confirmedPolicy !== undefined && + confirmedPolicy.state === "queueable" && + confirmedAction !== undefined && + !externalActionLock; + + function confirmAction(action: MaintenanceAction): void { + onActionErrorChange(undefined); + onActionStatusChange(undefined); + setConfirmedAction(action); + } + + async function runMaintenance(action: MaintenanceAction): Promise { + onActionErrorChange(undefined); + onActionStatusChange(undefined); + onRunningActionChange(action); + try { + const result = await onRequestMaintenance(action.policyId, action.dryRun); + setConfirmedAction(undefined); + onActionStatusChange( + `${result.dryRun ? "Dry run" : (confirmedPolicy?.label ?? "Log maintenance")} was added to the queue as job ${result.jobRunId}.` + ); + } catch (error) { + onActionErrorChange(logFailureMessage(error)); + } finally { + onRunningActionChange(undefined); + } + } + + const actionLabel = confirmedAction?.dryRun ? "Dry run" : "Run"; + const actionBusy = runningAction !== undefined; + + return ( + +
+
+ +
+ + Log maintenance + + + Dashboard rotates application and container logs. System log + cleanup uses the four configured Ubuntu cleanup jobs. + +
+
+ +
+ + onActionStatusChange(undefined)} + variant="success" + /> + onActionErrorChange(undefined)} + /> + {requestedRunRequest === undefined ? null : ( + + )} + {maintenanceLoading && maintenance === undefined ? ( + + ) : ( +
    + {(maintenance?.policies ?? []).map((policy) => { + const disabled = + actionBusy || + policy.state !== "queueable" || + externalActionLock; + return ( +
  • +
    +
    +

    + {policy.label} +

    +
    + + {policy.scope === "docker" + ? "Apps and containers" + : "System"} + + + {policy.state === "queueable" + ? "Ready" + : "Unavailable"} + +
    +
    +
    + +
    + + {policy.id === "docker-managed" && ( + + )} +
    +
  • + ); + })} +
+ )} + + {confirmationOpen && ( + { + onActionErrorChange(undefined); + setConfirmedAction(undefined); + }} + open + size="sm" + title={`${actionLabel} ${confirmedPolicy.label}?`} + > +
+ + +
+
+ )} +
+ ); +} + +/** + * Renders fixed-policy controls with durable lifecycle and bounded result status. + * @returns Accessible maintenance policy cards, confirmations, and job observations. + */ +export function LogMaintenancePanel(properties: LogMaintenancePanelProps) { + const [actionError, setActionError] = useState(); + const [actionStatus, setActionStatus] = useState(); + const [runningAction, setRunningAction] = useState(); + return ( + + ); +} diff --git a/greenfield/src/browser/logs/LogsBrowser.test.tsx b/greenfield/src/browser/logs/LogsBrowser.test.tsx index a8d54d3a6..3f09c2009 100644 --- a/greenfield/src/browser/logs/LogsBrowser.test.tsx +++ b/greenfield/src/browser/logs/LogsBrowser.test.tsx @@ -1,23 +1,46 @@ import { describe, expect, jest, test } from "bun:test"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { onlineManager, QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { AuthStatus } from "../../contracts/auth.ts"; +import type { RealtimeStreamOutput } from "../../contracts/events.ts"; +import type { JobRunSummary } from "../../contracts/jobModel.ts"; +import { jobRealtimeTopics } from "../../contracts/jobRealtime.ts"; +import type { JobRunDetail } from "../../contracts/jobs.ts"; import type { ListLogSourcesOutput, LogMaintenanceStatusOutput, LogSnapshotOutput, } from "../../contracts/logs.ts"; +import { logMaintenanceAvailabilityMaximumAgeMs } from "../../shared/logMaintenanceAvailabilityProjection.ts"; +import { DashboardRealtimeProvider } from "../api/realtimeContext.tsx"; import type { DashboardTrpcClient } from "../api/trpcClient.ts"; import { DashboardTrpcProvider } from "../api/trpcContext.tsx"; import { authStatusQueryKey } from "../auth/authQueries.ts"; +import { + ControlledDashboardRealtimeClient, + noOpDashboardRealtimeClient, +} from "../test/realtime.ts"; +import { + logMaintenanceQueryKey, + logMaintenanceRealtimeFallbackRefreshIntervalMs, +} from "./logQueries.ts"; import { LogsBrowser } from "./LogsBrowser.tsx"; -const { render, screen, waitFor } = await import("@testing-library/react"); +const { act, render, screen, waitFor } = await import("@testing-library/react"); const userEventModule = await import("@testing-library/user-event"); const userEvent = userEventModule.default; +function deferred() { + let resolveDeferred!: (value: T) => void; + const promise = new Promise((resolve) => { + resolveDeferred = resolve; + }); + return { promise, resolve: resolveDeferred }; +} + const observedAtMs = 1_800_000_000_000; +const maintenanceRunId = "019fdf70-0000-7000-8000-000000000020"; const authenticatedStatus = Object.freeze({ session: { authenticatedAtMs: observedAtMs, @@ -108,7 +131,87 @@ function snapshot(sourceId: string): LogSnapshotOutput { }; } -function renderBrowser(client: DashboardTrpcClient) { +function maintenanceRun(state: "queued" | "succeeded"): JobRunSummary { + const terminal = state === "succeeded"; + return { + actionKey: "maintenance.rotate-logs", + attemptCount: terminal ? 1 : 0, + attemptLimit: 1, + availableAtMs: observedAtMs, + cancellationPolicy: "cooperative", + displayName: "Managed log maintenance dry-run", + eventCount: terminal ? 3 : 1, + ...(terminal ? { finishedAtMs: observedAtMs + 2000 } : {}), + ...(terminal ? { firstStartedAtMs: observedAtMs + 1000 } : {}), + id: maintenanceRunId, + ...(terminal ? { lastAttemptStartedAtMs: observedAtMs + 1000 } : {}), + priority: 0, + queuedAtMs: observedAtMs, + resourceClass: "host-heavy", + resourceKeys: ["host.logs"], + retrySafe: false, + state, + stateVersion: terminal ? 3 : 1, + timeoutMs: 300_000, + triggerType: "system", + updatedAtMs: observedAtMs + (terminal ? 2000 : 0), + }; +} + +function maintenanceRunDetail(state: "queued" | "succeeded"): JobRunDetail { + return { + events: [], + ...(state === "succeeded" + ? { + result: { + completedAtMs: observedAtMs + 2000, + dryRun: true, + policyId: "docker-managed", + status: "completed", + summary: { + actionCounts: { + compressed: 1, + deleted: 2, + error: 0, + missing: 1, + rotated: 3, + skipped: 4, + }, + checkedTargets: 11, + dryRun: true, + finishedAtMs: observedAtMs + 2000, + ok: true, + startedAtMs: observedAtMs + 1000, + }, + }, + } + : {}), + run: maintenanceRun(state), + }; +} + +function runChange(): RealtimeStreamOutput { + return { + data: { + event: { + entityId: maintenanceRunId, + entityType: "job-run", + occurredAtMs: observedAtMs + 2000, + operation: "updated", + payload: { id: maintenanceRunId }, + topic: jobRealtimeTopics.runs, + }, + kind: "change", + }, + id: "21", + }; +} + +function renderBrowser( + client: DashboardTrpcClient, + realtimeClient = noOpDashboardRealtimeClient, + prepareQueryClient?: (queryClient: QueryClient) => void +) { const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false }, @@ -116,11 +219,14 @@ function renderBrowser(client: DashboardTrpcClient) { }, }); queryClient.setQueryData(authStatusQueryKey, authenticatedStatus); + prepareQueryClient?.(queryClient); const view = render( - - - + + + + + ); return { queryClient, view }; @@ -146,6 +252,9 @@ describe("LogsBrowser", () => { }; return Promise.resolve(snapshot(sourceId)); } + case "jobs.getRun": { + return Promise.resolve(maintenanceRunDetail("queued")); + } default: { return Promise.reject(new Error(`Unexpected query: ${name}`)); } @@ -153,7 +262,8 @@ describe("LogsBrowser", () => { }); const mutation = jest.fn(() => Promise.resolve({ - jobRunId: "log-maintenance-run", + dryRun: false, + jobRunId: maintenanceRunId, policyId: "docker-managed" as const, queued: true as const, }) @@ -176,6 +286,15 @@ describe("LogsBrowser", () => { ); const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Log rows" })); + await user.click(screen.getByRole("option", { name: "500 lines" })); + await waitFor(() => + expect(query).toHaveBeenCalledWith( + "logs.tail", + { limit: 500, sourceId: "dashboard.web.stderr" }, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + ); await user.type( screen.getByRole("searchbox", { name: "Search logs" }), "request-42" @@ -185,7 +304,7 @@ describe("LogsBrowser", () => { expect(query).toHaveBeenCalledWith( "logs.search", { - limit: 200, + limit: 500, query: "request-42", sourceId: "dashboard.web.stderr", }, @@ -202,7 +321,7 @@ describe("LogsBrowser", () => { await waitFor(() => expect(query).toHaveBeenCalledWith( "logs.tail", - { limit: 200, sourceId: "openclaw.gateway" }, + { limit: 500, sourceId: "openclaw.gateway" }, expect.objectContaining({ signal: expect.any(AbortSignal) }) ) ); @@ -231,6 +350,7 @@ describe("LogsBrowser", () => { expect(mutation).toHaveBeenCalledWith( "logs.requestMaintenance", { + dryRun: false, idempotencyKey: expect.stringMatching(/^[0-9a-f]{32}$/u), policyId: "docker-managed", }, @@ -238,7 +358,7 @@ describe("LogsBrowser", () => { ); expect( await screen.findByText( - /was added to the queue as job log-maintenance-run/u + new RegExp(`was added to the queue as job ${maintenanceRunId}`, "u") ) ).toBeVisible(); await waitFor(() => @@ -253,6 +373,330 @@ describe("LogsBrowser", () => { } }); + test("follows a queued dry-run through realtime completion and renders its bounded summary", async () => { + let requested = false; + let succeeded = false; + const query = jest.fn((name: string) => { + switch (name) { + case "logs.listSources": { + return Promise.resolve(sourceCatalog); + } + case "logs.maintenanceStatus": { + return Promise.resolve( + requested && !succeeded + ? { + ...maintenanceStatus, + policies: maintenanceStatus.policies.map((policy) => + policy.id === "docker-managed" + ? { + ...policy, + activeRun: maintenanceRun("queued"), + } + : policy + ), + } + : maintenanceStatus + ); + } + case "logs.tail": { + return Promise.resolve(snapshot("dashboard.web.stderr")); + } + case "jobs.getRun": { + return Promise.resolve( + maintenanceRunDetail(succeeded ? "succeeded" : "queued") + ); + } + default: { + return Promise.reject(new Error(`Unexpected query: ${name}`)); + } + } + }); + const mutation = jest.fn(() => { + requested = true; + return Promise.resolve({ + dryRun: true, + jobRunId: maintenanceRunId, + policyId: "docker-managed" as const, + queued: true as const, + }); + }); + const realtimeClient = new ControlledDashboardRealtimeClient(); + const { queryClient, view } = renderBrowser({ mutation, query }, realtimeClient); + + try { + await screen.findByRole("heading", { name: "Dashboard web stderr" }); + expect(realtimeClient.input).toEqual({ + lastEventId: "0", + topics: [jobRealtimeTopics.runs], + }); + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { + name: "Dry run Managed application and container logs", + }) + ); + await user.click(screen.getByRole("button", { name: "Queue dry run" })); + + await waitFor(() => + expect(mutation).toHaveBeenCalledWith( + "logs.requestMaintenance", + { + dryRun: true, + idempotencyKey: expect.stringMatching(/^[0-9a-f]{32}$/u), + policyId: "docker-managed", + }, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + ); + expect( + await screen.findByRole("status", { name: "Dry-run lifecycle" }) + ).toHaveTextContent("queued"); + expect( + screen.getByRole("button", { + name: "Dry run Managed application and container logs", + }) + ).toBeDisabled(); + expect(query).toHaveBeenCalledWith( + "jobs.getRun", + { eventLimit: 100, id: maintenanceRunId }, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + const maintenanceReadsBeforeChange = query.mock.calls.filter( + ([name]) => name === "logs.maintenanceStatus" + ).length; + + succeeded = true; + act(() => realtimeClient.emit(runChange())); + + await waitFor(() => + expect( + screen.getByRole("status", { name: "Dry-run lifecycle" }) + ).toHaveTextContent("succeeded") + ); + const summary = screen.getByLabelText("Dry-run result summary"); + expect(summary).toHaveTextContent("Checked11"); + expect(summary).toHaveTextContent("Rotated3"); + expect(summary).toHaveTextContent("Skipped4"); + expect( + screen.getByRole("button", { + name: "Dry run Managed application and container logs", + }) + ).toBeEnabled(); + await waitFor(() => + expect( + query.mock.calls.filter(([name]) => name === "logs.maintenanceStatus") + .length + ).toBeGreaterThan(maintenanceReadsBeforeChange) + ); + } finally { + view.unmount(); + queryClient.clear(); + } + }); + + test("starts the maintenance fallback cadence when realtime becomes unavailable", async () => { + const query = jest.fn((name: string) => { + switch (name) { + case "logs.listSources": { + return Promise.resolve(sourceCatalog); + } + case "logs.maintenanceStatus": { + return Promise.resolve(maintenanceStatus); + } + case "logs.tail": { + return Promise.resolve(snapshot("dashboard.web.stderr")); + } + default: { + return Promise.reject(new Error(`Unexpected query: ${name}`)); + } + } + }); + const realtimeClient = new ControlledDashboardRealtimeClient(); + const setInterval = jest.spyOn(globalThis, "setInterval"); + + try { + const { queryClient, view } = renderBrowser( + { + mutation: () => Promise.reject(new Error("Unexpected mutation")), + query, + }, + realtimeClient + ); + + try { + await screen.findByRole("heading", { name: "Dashboard web stderr" }); + const intervalCallsBeforeFailure = setInterval.mock.calls.length; + + act(() => realtimeClient.fail()); + + await waitFor(() => + expect( + setInterval.mock.calls + .slice(intervalCallsBeforeFailure) + .some( + ([, delay]) => + delay === + logMaintenanceRealtimeFallbackRefreshIntervalMs + ) + ).toBeTrue() + ); + } finally { + view.unmount(); + queryClient.clear(); + } + } finally { + setInterval.mockRestore(); + } + }); + + test("takes the inactivity baseline from cache after the maintenance mutation resolves", async () => { + const requestedRun = { + dryRun: true, + jobRunId: maintenanceRunId, + policyId: "docker-managed" as const, + queued: true as const, + }; + const mutationResult = deferred(); + const query = jest.fn((name: string) => { + switch (name) { + case "logs.listSources": { + return Promise.resolve(sourceCatalog); + } + case "logs.maintenanceStatus": { + return Promise.resolve(maintenanceStatus); + } + case "logs.tail": { + return Promise.resolve(snapshot("dashboard.web.stderr")); + } + case "jobs.getRun": { + return Promise.resolve(maintenanceRunDetail("queued")); + } + default: { + return Promise.reject(new Error(`Unexpected query: ${name}`)); + } + } + }); + const mutation = jest.fn(() => mutationResult.promise); + const { queryClient, view } = renderBrowser({ mutation, query }); + + try { + await screen.findByRole("heading", { name: "Dashboard web stderr" }); + const invalidate = jest + .spyOn(queryClient, "invalidateQueries") + .mockResolvedValue(); + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { + name: "Dry run Managed application and container logs", + }) + ); + await user.click(screen.getByRole("button", { name: "Queue dry run" })); + await waitFor(() => expect(mutation).toHaveBeenCalledTimes(1)); + + act(() => { + queryClient.setQueryData(logMaintenanceQueryKey, { + ...maintenanceStatus, + observedAtMs: observedAtMs + 1, + }); + }); + await act(async () => { + mutationResult.resolve(requestedRun); + await mutationResult.promise; + }); + + expect( + await screen.findByText( + new RegExp(`was added to the queue as job ${maintenanceRunId}`, "u") + ) + ).toBeVisible(); + const dryRunButton = screen.getByRole("button", { + name: "Dry run Managed application and container logs", + }); + expect(dryRunButton).toBeDisabled(); + invalidate.mockRestore(); + } finally { + view.unmount(); + queryClient.clear(); + } + }, 20_000); + + test("fails maintenance controls closed for an expired cached authority", async () => { + const pendingMaintenance = new Promise(() => {}); + const query = jest.fn((name: string) => { + if (name === "logs.maintenanceStatus") return pendingMaintenance; + if (name === "logs.listSources") return new Promise(() => {}); + return Promise.reject(new Error(`Unexpected query: ${name}`)); + }); + const client = { + mutation: () => Promise.reject(new Error("Unexpected mutation")), + query, + } as unknown as DashboardTrpcClient; + const { queryClient, view } = renderBrowser( + client, + noOpDashboardRealtimeClient, + (cache) => { + cache.setQueryData(logMaintenanceQueryKey, maintenanceStatus, { + updatedAt: Date.now() - logMaintenanceAvailabilityMaximumAgeMs - 1, + }); + } + ); + + try { + expect( + await screen.findByRole("button", { + name: "Run Managed application and container logs", + }) + ).toBeDisabled(); + expect( + screen.getByText("Maintenance status is temporarily unavailable.") + ).toBeVisible(); + } finally { + view.unmount(); + queryClient.clear(); + } + }); + + test("fails maintenance controls closed while the authority query is offline-paused", async () => { + const query = jest.fn((name: string) => { + if (name === "logs.maintenanceStatus") { + return Promise.resolve(maintenanceStatus); + } + return Promise.reject(new Error(`Unexpected query: ${name}`)); + }); + const client = { + mutation: () => Promise.reject(new Error("Unexpected mutation")), + query, + } as unknown as DashboardTrpcClient; + try { + onlineManager.setOnline(false); + const { queryClient, view } = renderBrowser( + client, + noOpDashboardRealtimeClient, + (cache) => { + cache.setQueryData(logMaintenanceQueryKey, maintenanceStatus, { + updatedAt: Date.now() - 11_000, + }); + } + ); + + try { + expect( + await screen.findByRole("button", { + name: "Run Managed application and container logs", + }) + ).toBeDisabled(); + expect( + screen.getByText("Maintenance status is temporarily unavailable.") + ).toBeVisible(); + } finally { + view.unmount(); + queryClient.clear(); + } + } finally { + onlineManager.setOnline(true); + } + }); + test("offers an explicit retry when the source catalog request fails", async () => { let sourceRequestCount = 0; const query = jest.fn((name: string) => { @@ -277,6 +721,9 @@ describe("LogsBrowser", () => { expect( await screen.findByRole("heading", { name: "Log sources unavailable" }) ).toBeVisible(); + expect( + screen.getByRole("heading", { name: "Log maintenance" }) + ).toBeVisible(); expect(screen.queryByText("private adapter failure")).toBeNull(); await userEvent @@ -352,6 +799,7 @@ describe("LogsBrowser", () => { "openclaw.gateway", "tail", null, + 200, ]) ).toMatchObject({ lines: [{ line: "sensitive cached line" }], @@ -382,6 +830,7 @@ describe("LogsBrowser", () => { "openclaw.gateway", "tail", null, + 200, ]) ).toMatchObject({ lines: [{ line: "sensitive cached line" }], @@ -394,4 +843,67 @@ describe("LogsBrowser", () => { queryClient.clear(); } }); + + test("hides cached snapshots after a snapshot refresh fails", async () => { + let snapshotUnavailable = false; + const query = jest.fn((name: string, input: unknown) => { + if (name === "logs.listSources") return Promise.resolve(sourceCatalog); + if (name === "logs.maintenanceStatus") { + return Promise.resolve(maintenanceStatus); + } + if (name === "logs.tail") { + if (snapshotUnavailable) { + return Promise.reject(new Error("private log adapter failure")); + } + const { sourceId } = input as { readonly sourceId: string }; + return Promise.resolve({ + ...snapshot(sourceId), + lines: [ + { + id: "f".repeat(64), + line: "sensitive cached line", + severity: "info" as const, + }, + ], + }); + } + return Promise.reject(new Error(`Unexpected query: ${name}`)); + }); + const client = { + mutation: () => Promise.reject(new Error("Unexpected mutation")), + query, + } as unknown as DashboardTrpcClient; + const { queryClient, view } = renderBrowser(client); + + try { + expect(await screen.findByText("1 line")).toBeVisible(); + snapshotUnavailable = true; + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Refresh" })); + + expect( + await screen.findByText("The request could not be completed. Try again.") + ).toBeVisible(); + await waitFor(() => expect(screen.queryByText("1 line")).toBeNull()); + expect( + screen.queryByRole("log", { + name: "Log lines with sensitive values removed", + }) + ).toBeNull(); + expect( + queryClient.getQueryData([ + "logs", + "snapshot", + "dashboard.web.stderr", + "tail", + null, + 200, + ]) + ).toMatchObject({ lines: [{ line: "sensitive cached line" }] }); + } finally { + view.unmount(); + queryClient.clear(); + } + }); }); diff --git a/greenfield/src/browser/logs/LogsBrowser.tsx b/greenfield/src/browser/logs/LogsBrowser.tsx index d3703e1ba..42c7cfa21 100644 --- a/greenfield/src/browser/logs/LogsBrowser.tsx +++ b/greenfield/src/browser/logs/LogsBrowser.tsx @@ -1,29 +1,72 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; +import { useEffect, useState } from "react"; -import type { LogMaintenancePolicyId } from "../../contracts/logs.ts"; +import { jobRealtimeTopics } from "../../contracts/jobRealtime.ts"; +import type { + LogMaintenancePolicyId, + LogMaintenanceStatusOutput, + RequestLogMaintenanceOutput, +} from "../../contracts/logs.ts"; +import { logTailDefaultRows } from "../../contracts/logs.ts"; +import { logMaintenanceAvailabilityMaximumAgeMs } from "../../shared/logMaintenanceAvailabilityProjection.ts"; import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { useRealtimeQueryInvalidation } from "../api/useRealtimeQueryInvalidation.ts"; import { useAuthenticatedMutationBoundary } from "../auth/useAuthenticatedMutationBoundary.ts"; -import { PageState } from "../ui/PageState.tsx"; +import { jobRunDetailQueryOptions } from "../jobs/jobQueries.ts"; import { logClient } from "./logClient.ts"; import { logFailureMessage } from "./logPresentation.ts"; import { - logMaintenanceQueryKey, logMaintenanceQueryOptions, + logMaintenanceQueryKey, + logMaintenanceRealtimeFallbackRefreshIntervalMs, + logMaintenanceRealtimeRefreshDelayMs, logSnapshotQueryOptions, logSourcesQueryOptions, + refreshLogMaintenanceQueries, type LogSnapshotSelection, } from "./logQueries.ts"; import { LogsView } from "./LogsView.tsx"; +const disabledRunId = "00000000-0000-7000-8000-000000000000"; +const unavailableMaintenanceAuthorityMessage = + "Maintenance status is temporarily unavailable."; + +interface RequestedRunTracking { + readonly maintenanceDataUpdatedAt: number; + readonly maintenanceObservedAtMs?: number; + readonly request: RequestLogMaintenanceOutput; +} + /** @returns Session-scoped source inventory, redacted snapshots, and maintenance actions. */ export function LogsBrowser() { - const client = logClient(useDashboardTrpcClient()); + const dashboardClient = useDashboardTrpcClient(); + const client = logClient(dashboardClient); const queryClient = useQueryClient(); const mutationBoundary = useAuthenticatedMutationBoundary(); const sourcesQuery = useQuery(logSourcesQueryOptions(client)); const maintenanceQuery = useQuery(logMaintenanceQueryOptions(client)); + const [maintenanceAuthorityNowMs, setMaintenanceAuthorityNowMs] = useState(() => + Date.now() + ); + const [requestedRunTracking, setRequestedRunTracking] = + useState(); + const requestedRunRequest = requestedRunTracking?.request; + const requestedRunQuery = useQuery({ + ...jobRunDetailQueryOptions( + dashboardClient, + requestedRunRequest?.jobRunId ?? disabledRunId + ), + enabled: requestedRunRequest !== undefined, + }); + useRealtimeQueryInvalidation({ + fallbackRefreshIntervalMs: logMaintenanceRealtimeFallbackRefreshIntervalMs, + refreshDelayMs: logMaintenanceRealtimeRefreshDelayMs, + refreshQueries: (cache) => + refreshLogMaintenanceQueries(cache, requestedRunRequest?.jobRunId), + topic: jobRealtimeTopics.runs, + }); const [selectedSourceId, setSelectedSourceId] = useState(); + const [rowCount, setRowCount] = useState(logTailDefaultRows); const [search, setSearch] = useState>(); const sources = sourcesQuery.data?.sources ?? []; @@ -37,36 +80,39 @@ export function LogsBrowser() { selection = search?.sourceId === selectedSource.id ? { + limit: rowCount, mode: "search", query: search.query, sourceId: selectedSource.id, } - : { mode: "tail", sourceId: selectedSource.id }; + : { limit: rowCount, mode: "tail", sourceId: selectedSource.id }; } const snapshotQuery = useQuery( logSnapshotQueryOptions(client, selection, sourceAvailable) ); - if (sourcesQuery.isPending && sourcesQuery.data === undefined) { - return ; - } - if (sourcesQuery.data === undefined) { - return ( - void sourcesQuery.refetch()} - retryBusy={sourcesQuery.isFetching} - status="error" - title="Log sources unavailable" - /> - ); - } + useEffect(() => { + if (maintenanceQuery.data === undefined || maintenanceQuery.dataUpdatedAt <= 0) { + return; + } + const expiresInMs = + maintenanceQuery.dataUpdatedAt + + logMaintenanceAvailabilityMaximumAgeMs - + Date.now() + + 1; + if (expiresInMs <= 0) return; + const timeout = globalThis.setTimeout(() => { + setMaintenanceAuthorityNowMs(Date.now()); + }, expiresInMs); + return () => globalThis.clearTimeout(timeout); + }, [maintenanceQuery.data, maintenanceQuery.dataUpdatedAt]); - async function requestMaintenance(policyId: LogMaintenancePolicyId) { + async function requestMaintenance(policyId: LogMaintenancePolicyId, dryRun: boolean) { const result = await mutationBoundary.run((signal) => client.mutation( "logs.requestMaintenance", { + dryRun, idempotencyKey: globalThis.crypto.randomUUID().replaceAll("-", ""), policyId, }, @@ -74,37 +120,77 @@ export function LogsBrowser() { ) ); if (mutationBoundary.completionIsCurrent()) { - await queryClient.invalidateQueries({ - exact: true, - queryKey: logMaintenanceQueryKey, + const currentMaintenanceState = + queryClient.getQueryState( + logMaintenanceQueryKey + ); + setRequestedRunTracking({ + maintenanceDataUpdatedAt: currentMaintenanceState?.dataUpdatedAt ?? 0, + maintenanceObservedAtMs: currentMaintenanceState?.data?.observedAtMs, + request: result, }); + await refreshLogMaintenanceQueries(queryClient, result.jobRunId); } return result; } + async function refreshMaintenance(): Promise { + await Promise.allSettled([ + maintenanceQuery.refetch(), + ...(requestedRunRequest === undefined ? [] : [requestedRunQuery.refetch()]), + ]); + } + + async function refreshAll(): Promise { + const maintenanceRefresh = refreshMaintenance(); + const refreshedSources = await sourcesQuery.refetch(); + const refreshedSelection = refreshedSources.data?.sources.find( + ({ id }) => id === selectedSource?.id + ); + if (refreshedSelection?.availability === "available") { + await snapshotQuery.refetch(); + } + await maintenanceRefresh; + } + + const globalMaintenanceActive = + maintenanceQuery.data?.policies.some( + ({ activeRun }) => activeRun !== undefined + ) ?? false; + const maintenanceAuthorityExpired = + maintenanceQuery.data !== undefined && + (maintenanceQuery.dataUpdatedAt <= 0 || + maintenanceAuthorityNowMs - maintenanceQuery.dataUpdatedAt > + logMaintenanceAvailabilityMaximumAgeMs); + const maintenanceAuthorityUnavailable = + maintenanceQuery.error !== null || + maintenanceQuery.isPaused || + maintenanceAuthorityExpired; + const maintenanceUpdatedAfterRequest = + requestedRunTracking !== undefined && + (maintenanceQuery.dataUpdatedAt > requestedRunTracking.maintenanceDataUpdatedAt || + (maintenanceQuery.data?.observedAtMs !== undefined && + maintenanceQuery.data.observedAtMs > + (requestedRunTracking.maintenanceObservedAtMs ?? 0))); + const requestedRunInactiveConfirmed = + maintenanceUpdatedAfterRequest && + !maintenanceAuthorityUnavailable && + !globalMaintenanceActive; + let maintenanceError: string | undefined; + if (maintenanceQuery.error !== null) { + maintenanceError = logFailureMessage(maintenanceQuery.error); + } else if (maintenanceQuery.isPaused || maintenanceAuthorityExpired) { + maintenanceError = unavailableMaintenanceAuthorityMessage; + } return ( setSearch(undefined)} - onRefresh={() => { - void (async () => { - const maintenanceRefresh = maintenanceQuery.refetch(); - const refreshedSources = await sourcesQuery.refetch(); - const refreshedSelection = refreshedSources.data?.sources.find( - ({ id }) => id === selectedSource?.id - ); - if (refreshedSelection?.availability === "available") { - await snapshotQuery.refetch(); - } - await maintenanceRefresh; - })(); - }} + onRefresh={() => void refreshAll()} + onRefreshMaintenance={() => void refreshMaintenance()} onRequestMaintenance={requestMaintenance} onSearch={(query) => { if (selectedSource !== undefined && sourceAvailable) { @@ -115,24 +201,46 @@ export function LogsBrowser() { setSelectedSourceId(sourceId); setSearch(undefined); }} + onRowCountChange={setRowCount} refreshing={ sourcesQuery.isRefetching || - (sourceAvailable && snapshotQuery.isRefetching) || - maintenanceQuery.isRefetching + (sourceAvailable && snapshotQuery.isRefetching) } + requestedRun={requestedRunQuery.data} + requestedRunError={ + requestedRunQuery.error === null + ? undefined + : logFailureMessage(requestedRunQuery.error) + } + requestedRunLoading={ + requestedRunRequest !== undefined && requestedRunQuery.isPending + } + requestedRunInactiveConfirmed={requestedRunInactiveConfirmed} + requestedRunRequest={requestedRunRequest} + rowCount={rowCount} searchQuery={ sourceAvailable && selection?.mode === "search" ? selection.query : undefined } selectedSourceId={selectedSource?.id} - snapshot={sourceAvailable ? snapshotQuery.data : undefined} + snapshot={ + sourceAvailable && snapshotQuery.error === null + ? snapshotQuery.data + : undefined + } snapshotError={ !sourceAvailable || snapshotQuery.error === null ? undefined : logFailureMessage(snapshotQuery.error) } snapshotLoading={sourceAvailable && snapshotQuery.isPending} + sourcesError={ + sourcesQuery.error === null + ? undefined + : logFailureMessage(sourcesQuery.error) + } + sourcesLoading={sourcesQuery.isPending && sourcesQuery.data === undefined} sources={sources} /> ); diff --git a/greenfield/src/browser/logs/LogsRoute.test.tsx b/greenfield/src/browser/logs/LogsRoute.test.tsx index 8ca873717..aa9a46b30 100644 --- a/greenfield/src/browser/logs/LogsRoute.test.tsx +++ b/greenfield/src/browser/logs/LogsRoute.test.tsx @@ -2,13 +2,54 @@ import { expect, test } from "bun:test"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { LogMaintenanceStatusOutput } from "../../contracts/logs.ts"; +import { DashboardRealtimeProvider } from "../api/realtimeContext.tsx"; import type { DashboardTrpcClient } from "../api/trpcClient.ts"; import { DashboardTrpcProvider } from "../api/trpcContext.tsx"; +import { noOpDashboardRealtimeClient } from "../test/realtime.ts"; import { logMaintenanceQueryKey, logSourcesQueryKey } from "./logQueries.ts"; import { LogsRoute } from "./LogsRoute.tsx"; const { render, screen } = await import("@testing-library/react"); +function maintenanceStatus(observedAtMs: number): LogMaintenanceStatusOutput { + return { + observedAtMs, + policies: [ + { + id: "docker-managed", + label: "Managed application and container logs", + scope: "docker", + state: "queueable", + }, + { + id: "host-alternatives", + label: "Host alternatives log", + scope: "host", + state: "unavailable", + }, + { + id: "host-apport", + label: "Host Apport log", + scope: "host", + state: "unavailable", + }, + { + id: "host-dpkg", + label: "Host package log", + scope: "host", + state: "unavailable", + }, + { + id: "host-rsyslog", + label: "Host system logs", + scope: "host", + state: "unavailable", + }, + ], + }; +} + test("logs route explains the redacted operator surface and composes its browser", async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -19,20 +60,20 @@ test("logs route explains the redacted operator surface and composes its browser { observedAtMs, sources: [] }, { updatedAt: observedAtMs } ); - queryClient.setQueryData( - logMaintenanceQueryKey, - { observedAtMs, policies: [] }, - { updatedAt: observedAtMs } - ); + queryClient.setQueryData(logMaintenanceQueryKey, maintenanceStatus(observedAtMs), { + updatedAt: observedAtMs, + }); const client = { mutation: () => Promise.reject(new Error("Unexpected mutation")), query: () => Promise.reject(new Error("Unexpected query")), } as unknown as DashboardTrpcClient; const view = render( - - - + + + + + ); diff --git a/greenfield/src/browser/logs/LogsView.test.tsx b/greenfield/src/browser/logs/LogsView.test.tsx index ccf5d1f57..67dbcdf89 100644 --- a/greenfield/src/browser/logs/LogsView.test.tsx +++ b/greenfield/src/browser/logs/LogsView.test.tsx @@ -1,6 +1,8 @@ import { afterAll, beforeAll, describe, expect, jest, test } from "bun:test"; +import type { JobRunSummary } from "../../contracts/jobModel.ts"; import type { + LogMaintenancePolicyId, LogMaintenanceStatusOutput, LogSnapshotOutput, LogSource, @@ -12,6 +14,16 @@ const { act, fireEvent, render, screen, waitFor, within } = const userEventModule = await import("@testing-library/user-event"); const userEvent = userEventModule.default; +function deferred() { + let resolveDeferred!: (value: T) => void; + let rejectDeferred!: (reason?: unknown) => void; + const promise = new Promise((resolve, reject) => { + resolveDeferred = resolve; + rejectDeferred = reject; + }); + return { promise, reject: rejectDeferred, resolve: resolveDeferred }; +} + const hadOwnResizeObserver = Object.hasOwn(globalThis, "ResizeObserver"); const originalResizeObserver = Reflect.get(globalThis, "ResizeObserver"); const originalOffsetHeight = Object.getOwnPropertyDescriptor( @@ -144,8 +156,10 @@ function properties() { maintenance, onClearSearch: jest.fn(), onRefresh: jest.fn(), + onRefreshMaintenance: jest.fn(), onRequestMaintenance: jest.fn(() => Promise.resolve({ + dryRun: false, jobRunId: "log-maintenance-run", policyId: "docker-managed" as const, queued: true as const, @@ -153,12 +167,45 @@ function properties() { ), onSearch: jest.fn(), onSelectSource: jest.fn(), + onRowCountChange: jest.fn(), + rowCount: 200, selectedSourceId: sources[0]!.id, snapshot, sources, }; } +function maintenanceRun( + state: "queued" | "succeeded", + id: string, + timestampMs: number +): JobRunSummary { + const started = state === "succeeded"; + return { + actionKey: "maintenance.rotate-logs", + attemptCount: started ? 1 : 0, + attemptLimit: 1, + availableAtMs: timestampMs, + cancellationPolicy: "cooperative", + displayName: "Managed log maintenance", + eventCount: started ? 3 : 1, + ...(started ? { finishedAtMs: timestampMs + 2000 } : {}), + ...(started ? { firstStartedAtMs: timestampMs + 1000 } : {}), + id, + ...(started ? { lastAttemptStartedAtMs: timestampMs + 1000 } : {}), + priority: 0, + queuedAtMs: timestampMs, + resourceClass: "host-heavy", + resourceKeys: ["host.logs"], + retrySafe: false, + state, + stateVersion: started ? 3 : 1, + timeoutMs: 300_000, + triggerType: "system", + updatedAtMs: timestampMs + (started ? 2000 : 0), + }; +} + function stableId(index: number): string { return index.toString(16).padStart(64, "0"); } @@ -234,6 +281,135 @@ describe("LogsView", () => { expect(props.onSearch).toHaveBeenCalledWith("request-42"); }); + test("exports only the bounded server-redacted lines as inert text", async () => { + const exportedBlobs: Blob[] = []; + const createObjectUrl = jest.fn((blob: Blob) => { + exportedBlobs.push(blob); + return "blob:redacted-log-export"; + }); + const revokeObjectUrl = jest.fn(); + const createDescriptor = Object.getOwnPropertyDescriptor(URL, "createObjectURL"); + const revokeDescriptor = Object.getOwnPropertyDescriptor(URL, "revokeObjectURL"); + let activatedDownload: HTMLAnchorElement | undefined; + const click = jest + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => { + activatedDownload = + document.body.querySelector("a[download]") ?? + undefined; + }); + Object.defineProperties(URL, { + createObjectURL: { configurable: true, value: createObjectUrl }, + revokeObjectURL: { configurable: true, value: revokeObjectUrl }, + }); + + try { + render(); + await userEvent.setup().click(screen.getByRole("button", { name: "Export" })); + + expect(exportedBlobs).toHaveLength(1); + expect(exportedBlobs[0]?.type).toBe("text/plain;charset=utf-8"); + expect(await exportedBlobs[0]?.text()).toBe( + snapshot.lines.map(({ line }) => line).join("\n") + ); + expect(activatedDownload?.download).toBe( + `mira-dashboard-${snapshot.sourceId}-${snapshot.revision.slice(0, 12)}.log` + ); + expect(activatedDownload?.href).toBe("blob:redacted-log-export"); + expect(document.body.contains(activatedDownload ?? null)).toBeFalse(); + expect(revokeObjectUrl).toHaveBeenCalledWith("blob:redacted-log-export"); + } finally { + click.mockRestore(); + if (createDescriptor === undefined) { + Reflect.deleteProperty(URL, "createObjectURL"); + } else { + Object.defineProperty(URL, "createObjectURL", createDescriptor); + } + if (revokeDescriptor === undefined) { + Reflect.deleteProperty(URL, "revokeObjectURL"); + } else { + Object.defineProperty(URL, "revokeObjectURL", revokeDescriptor); + } + } + }); + + test("clears the bounded current buffer without hiding new rows or crossing scopes", async () => { + const rendered = render(); + const user = userEvent.setup(); + + await user.click(screen.getByRole("button", { name: "Clear buffer" })); + expect( + screen.getByRole("heading", { name: "Current log buffer cleared" }) + ).toBeVisible(); + expect(screen.queryByText(/Credential \[REDACTED\]/u)).toBeNull(); + + const appendedLine = { + id: "d".repeat(64), + line: "worker: newly appended redacted row", + severity: "info" as const, + }; + const refreshedSnapshot: LogSnapshotOutput = { + ...snapshot, + lines: [...snapshot.lines, appendedLine], + revision: "e".repeat(64), + }; + rendered.rerender(); + + expect(await screen.findByText("newly appended redacted row")).toBeVisible(); + expect(screen.queryByText(/Credential \[REDACTED\]/u)).toBeNull(); + expect(screen.queryByText(/raw \[REDACTED\]/u)).toBeNull(); + + rendered.rerender( + + ); + expect(await screen.findByText(/Credential \[REDACTED\]/u)).toBeVisible(); + expect(screen.getByText(/raw \[REDACTED\]/u)).toBeVisible(); + }); + + test("clears the complete buffer when level filters hide every row", async () => { + const rendered = render(); + const user = userEvent.setup(); + + await user.click(screen.getByRole("button", { name: "Clear all log levels" })); + expect( + screen.getByRole("heading", { + name: "No log lines at the selected levels", + }) + ).toBeVisible(); + const clearBuffer = screen.getByRole("button", { name: "Clear buffer" }); + expect(clearBuffer).toBeEnabled(); + + await user.click(clearBuffer); + expect( + screen.getByRole("heading", { name: "Current log buffer cleared" }) + ).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Select all log levels" })); + expect(screen.queryByText(/Credential \[REDACTED\]/u)).toBeNull(); + expect(screen.queryByText(/raw \[REDACTED\]/u)).toBeNull(); + + const appendedLine = { + id: "d".repeat(64), + line: "worker: genuinely new row after hidden-level clear", + severity: "info" as const, + }; + rendered.rerender( + + ); + + expect( + await screen.findByText("genuinely new row after hidden-level clear") + ).toBeVisible(); + expect(screen.queryByText(/Credential \[REDACTED\]/u)).toBeNull(); + expect(screen.queryByText(/raw \[REDACTED\]/u)).toBeNull(); + }); + test("balances source and search fields with an aria-hidden description row", () => { const { container } = render(); const spacer = container.querySelector( @@ -390,8 +566,18 @@ describe("LogsView", () => { snapshot={scrollingSnapshot(12, "4", sources[1]!.id)} /> ); + const resetLog = screen.getByRole("log", { + name: "Log lines with sensitive values removed", + }); + Object.defineProperties(resetLog, { + clientHeight: { configurable: true, value: 200 }, + scrollHeight: { + configurable: true, + get: () => scrollHeight, + }, + }); await flushAnimationFrames(); - expect(log.scrollTop).toBe(900); + expect(resetLog.scrollTop).toBe(900); act(() => rendered.unmount()); }); @@ -411,13 +597,576 @@ describe("LogsView", () => { await user.click(within(dialog).getByRole("button", { name: "Add to queue" })); await waitFor(() => - expect(props.onRequestMaintenance).toHaveBeenCalledWith("docker-managed") + expect(props.onRequestMaintenance).toHaveBeenCalledWith( + "docker-managed", + false + ) ); expect( await screen.findByText(/was added to the queue as job log-maintenance-run/u) ).toBeTruthy(); }); + test("closes confirmation before an unavailable policy can be submitted", async () => { + const props = properties(); + const user = userEvent.setup(); + const rendered = render(); + + await user.click( + screen.getByRole("button", { + name: "Run Managed application and container logs", + }) + ); + expect(screen.getByRole("dialog")).toBeVisible(); + + rendered.rerender( + + policy.id === "docker-managed" + ? { ...policy, state: "unavailable" as const } + : policy + ), + }} + /> + ); + + expect(screen.queryByRole("dialog")).toBeNull(); + expect( + screen.getByRole("button", { + name: "Run Managed application and container logs", + }) + ).toBeDisabled(); + expect(props.onRequestMaintenance).not.toHaveBeenCalled(); + }); + + test("confirms the docker-only dry-run separately and announces its queued job", async () => { + const props = { + ...properties(), + onRequestMaintenance: jest.fn( + (policyId: LogMaintenancePolicyId, dryRun: boolean) => + Promise.resolve({ + dryRun, + jobRunId: "dry-run-job", + policyId, + queued: true as const, + }) + ), + }; + const user = userEvent.setup(); + render(); + + expect(screen.queryByRole("button", { name: /Dry run Host/u })).toBeNull(); + await user.click( + screen.getByRole("button", { + name: "Dry run Managed application and container logs", + }) + ); + const dialog = screen.getByRole("dialog", { + name: "Dry run Managed application and container logs?", + }); + expect(dialog).toHaveTextContent("read-only preview"); + await user.click(within(dialog).getByRole("button", { name: "Queue dry run" })); + + await waitFor(() => + expect(props.onRequestMaintenance).toHaveBeenCalledWith( + "docker-managed", + true + ) + ); + expect( + await screen.findByText(/Dry run was added to the queue as job dry-run-job/u) + ).toBeVisible(); + }); + + test("presents active lifecycle and the last terminal bounded summary", () => { + const activeRun = maintenanceRun( + "queued", + "019fdf70-0000-7000-8000-000000000010", + 1_800_000_002_000 + ); + const lastRun = maintenanceRun( + "succeeded", + "019fdf70-0000-7000-8000-000000000011", + 1_800_000_000_000 + ); + const lifecycle: LogMaintenanceStatusOutput = { + ...maintenance, + policies: maintenance.policies.map((policy) => { + if (policy.id === "docker-managed") { + return { + ...policy, + activeRun, + lastRun: { + run: lastRun, + summary: { + actionCounts: { + compressed: 2, + deleted: 1, + error: 0, + missing: 0, + rotated: 3, + skipped: 1, + }, + checkedTargets: 7, + dryRun: false, + finishedAtMs: 1_800_000_002_000, + ok: true, + startedAtMs: 1_800_000_001_000, + }, + }, + }; + } + return policy.id === "host-alternatives" + ? { ...policy, state: "queueable" as const } + : policy; + }), + }; + render(); + + expect( + screen.getByRole("status", { + name: "Active maintenance run for Managed application and container logs", + }) + ).toHaveTextContent("queued"); + expect( + screen.getByLabelText( + "Last maintenance run for Managed application and container logs" + ) + ).toHaveTextContent("succeeded"); + const summary = screen.getByLabelText( + "Managed application and container logs last-run summary" + ); + expect(summary).toHaveTextContent("Checked7"); + expect(summary).toHaveTextContent("Rotated3"); + expect( + screen.getByRole("button", { + name: "Run Managed application and container logs", + }) + ).toBeDisabled(); + expect( + screen.getByRole("button", { + name: "Dry run Managed application and container logs", + }) + ).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Run Host alternatives log" }) + ).toBeDisabled(); + }); + + test("warns only when a succeeded managed run has no verified result summary", () => { + const managedRun = maintenanceRun( + "succeeded", + "019fdf70-0000-7000-8000-000000000014", + 1_800_000_002_000 + ); + const hostRun = maintenanceRun( + "succeeded", + "019fdf70-0000-7000-8000-000000000015", + 1_800_000_003_000 + ); + const lifecycle: LogMaintenanceStatusOutput = { + ...maintenance, + policies: maintenance.policies.map((policy) => { + if (policy.id === "docker-managed") { + return { ...policy, lastRun: { run: managedRun } }; + } + return policy.id === "host-alternatives" + ? { ...policy, lastRun: { run: hostRun } } + : policy; + }), + }; + + render(); + + expect( + screen.getAllByText( + "The durable maintenance result could not be verified. Lifecycle status remains visible, but result details are hidden." + ) + ).toHaveLength(1); + expect( + screen.getByLabelText("Last maintenance run for Host alternatives log") + ).toHaveTextContent("succeeded"); + }); + + test("keeps maintenance and its explicit refresh available without log sources", async () => { + const props = properties(); + render( + + ); + + expect(screen.getByRole("heading", { name: "No log sources" })).toBeVisible(); + expect(screen.getByRole("heading", { name: "Log maintenance" })).toBeVisible(); + expect( + screen.getByRole("button", { + name: "Run Managed application and container logs", + }) + ).toBeEnabled(); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Refresh sources" })); + expect(props.onRefresh).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole("button", { name: "Refresh maintenance" })); + expect(props.onRefreshMaintenance).toHaveBeenCalledTimes(1); + }); + + test("disables cached maintenance actions after a status refetch error", () => { + render( + + ); + + expect( + screen.getByRole("button", { + name: "Run Managed application and container logs", + }) + ).toBeDisabled(); + expect( + screen.getByText("Maintenance status is temporarily unavailable.") + ).toBeVisible(); + }); + + test("closes an open confirmation when authoritative status reports a global run", () => { + const rendered = render(); + act(() => { + fireEvent.click( + screen.getByRole("button", { + name: "Run Managed application and container logs", + }) + ); + }); + expect( + screen.getByRole("dialog", { + name: "Run Managed application and container logs?", + }) + ).toBeVisible(); + + rendered.rerender( + + ); + expect( + screen.getByRole("dialog", { + name: "Run Managed application and container logs?", + }) + ).toBeVisible(); + + const activeRun = maintenanceRun( + "queued", + "019fdf70-0000-7000-8000-000000000099", + 1_800_000_004_000 + ); + const activeMaintenance: LogMaintenanceStatusOutput = { + ...maintenance, + observedAtMs: maintenance.observedAtMs + 2, + policies: maintenance.policies.map((policy) => + policy.id === "docker-managed" ? { ...policy, activeRun } : policy + ), + }; + rendered.rerender(); + expect(screen.queryByRole("dialog")).toBeNull(); + expect( + screen.getByRole("button", { name: "Run Host alternatives log" }) + ).toBeDisabled(); + + rendered.rerender( + + ); + expect(screen.queryByRole("dialog")).toBeNull(); + expect( + screen.getByRole("button", { + name: "Run Managed application and container logs", + }) + ).toBeEnabled(); + act(() => rendered.unmount()); + }); + + test("keeps every action busy across a transient authority lock until the request settles", async () => { + const requestResult = { + dryRun: false, + jobRunId: "019fdf70-0000-7000-8000-000000000018", + policyId: "docker-managed" as const, + queued: true as const, + }; + const pendingRequest = deferred(); + const queueableMaintenance: LogMaintenanceStatusOutput = { + ...maintenance, + policies: maintenance.policies.map((policy) => ({ + ...policy, + state: "queueable" as const, + })), + }; + const props = { + ...properties(), + maintenance: queueableMaintenance, + onRequestMaintenance: jest.fn(() => pendingRequest.promise), + }; + const rendered = render(); + const user = userEvent.setup(); + const dockerRun = () => + screen.getByRole("button", { + name: "Run Managed application and container logs", + }); + const dockerDryRun = () => + screen.getByRole("button", { + name: "Dry run Managed application and container logs", + }); + const hostRun = () => + screen.getByRole("button", { name: "Run Host alternatives log" }); + + await user.click(dockerRun()); + await user.click(screen.getByRole("button", { name: "Add to queue" })); + await waitFor(() => expect(props.onRequestMaintenance).toHaveBeenCalledTimes(1)); + expect(dockerRun()).toBeDisabled(); + expect(dockerDryRun()).toBeDisabled(); + expect(hostRun()).toBeDisabled(); + + rendered.rerender( + + ); + expect(dockerRun()).toBeDisabled(); + expect(dockerDryRun()).toBeDisabled(); + expect(hostRun()).toBeDisabled(); + + rendered.rerender(); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(dockerRun()).toBeDisabled(); + expect(dockerDryRun()).toBeDisabled(); + expect(hostRun()).toBeDisabled(); + expect(props.onRequestMaintenance).toHaveBeenCalledTimes(1); + + await act(async () => { + pendingRequest.resolve(requestResult); + await pendingRequest.promise; + }); + expect( + await screen.findByText( + new RegExp(`was added to the queue as job ${requestResult.jobRunId}`, "u") + ) + ).toBeVisible(); + expect(dockerRun()).toBeEnabled(); + expect(dockerDryRun()).toBeEnabled(); + expect(hostRun()).toBeEnabled(); + }); + + test("keeps a failed maintenance request visible after authority lock closes its modal", async () => { + const pendingRequest = deferred(); + const props = { + ...properties(), + onRequestMaintenance: jest.fn(() => pendingRequest.promise), + }; + const rendered = render(); + const user = userEvent.setup(); + + await user.click( + screen.getByRole("button", { + name: "Run Managed application and container logs", + }) + ); + await user.click(screen.getByRole("button", { name: "Add to queue" })); + await waitFor(() => expect(props.onRequestMaintenance).toHaveBeenCalledTimes(1)); + + rendered.rerender( + + ); + expect(screen.queryByRole("dialog")).toBeNull(); + + await act(async () => { + pendingRequest.reject(new Error("private queue adapter failure")); + await pendingRequest.promise.catch(() => {}); + }); + + expect( + await screen.findByText("The request could not be completed. Try again.") + ).toBeVisible(); + expect(screen.queryByText("private queue adapter failure")).toBeNull(); + }); + + test("fails closed on requested-run detail errors until status confirms inactivity", () => { + const runId = "019fdf70-0000-7000-8000-000000000012"; + const requestedRunRequest = { + dryRun: true, + jobRunId: runId, + policyId: "docker-managed" as const, + queued: true as const, + }; + const queueableMaintenance: LogMaintenanceStatusOutput = { + ...maintenance, + policies: maintenance.policies.map((policy) => ({ + ...policy, + state: "queueable" as const, + })), + }; + const pendingProperties = { + ...properties(), + maintenance: queueableMaintenance, + requestedRunLoading: true, + requestedRunRequest, + }; + const rendered = render(); + const dockerRun = () => + screen.getByRole("button", { + name: "Run Managed application and container logs", + }); + const hostRun = () => + screen.getByRole("button", { + name: "Run Host alternatives log", + }); + + expect(dockerRun()).toBeDisabled(); + expect(hostRun()).toBeDisabled(); + + rendered.rerender( + + ); + expect(dockerRun()).toBeDisabled(); + expect(hostRun()).toBeDisabled(); + + rendered.rerender( + + ); + expect(dockerRun()).toBeEnabled(); + expect(hostRun()).toBeEnabled(); + + rendered.rerender( + + ); + expect(dockerRun()).toBeDisabled(); + expect(hostRun()).toBeDisabled(); + + rendered.rerender( + + ); + expect(dockerRun()).toBeEnabled(); + expect(hostRun()).toBeEnabled(); + }); + + test("hides corrupt durable maintenance result details behind a fixed warning", () => { + const runId = "019fdf70-0000-7000-8000-000000000013"; + render( + + ); + + expect( + screen.getByText( + "The durable maintenance result could not be verified. Lifecycle status remains visible, but result details are hidden." + ) + ).toBeVisible(); + expect(screen.getByLabelText("Dry-run lifecycle")).toHaveTextContent("succeeded"); + expect(screen.queryByText(/private-provider-value/u)).toBeNull(); + }); + + test("warns for a succeeded managed request with missing result details but not a host run", () => { + const managedRunId = "019fdf70-0000-7000-8000-000000000016"; + const rendered = render( + + ); + const warning = + "The durable maintenance result could not be verified. Lifecycle status remains visible, but result details are hidden."; + + expect(screen.getByText(warning)).toBeVisible(); + + const hostRunId = "019fdf70-0000-7000-8000-000000000017"; + rendered.rerender( + + ); + + expect(screen.queryByText(warning)).toBeNull(); + expect(screen.getByLabelText("Maintenance run lifecycle")).toHaveTextContent( + "succeeded" + ); + }); + test("keeps unavailable sources and policies visible but disabled", () => { render(); expect( diff --git a/greenfield/src/browser/logs/LogsView.tsx b/greenfield/src/browser/logs/LogsView.tsx index a191a668d..7932ca8a1 100644 --- a/greenfield/src/browser/logs/LogsView.tsx +++ b/greenfield/src/browser/logs/LogsView.tsx @@ -1,9 +1,11 @@ -import { ArrowDown, RefreshCw, RotateCcw, Search, ShieldCheck, X } from "lucide-react"; +import { ArrowDown, Download, RefreshCw, Search, Trash2, X } from "lucide-react"; import { type ReactNode, useState } from "react"; /* eslint-disable jsx-a11y/no-noninteractive-tabindex -- The scrollable log must be keyboard-focusable. */ +import type { JobRunDetail } from "../../contracts/jobs.ts"; import { logSearchMaximumCharacters, + type LogLine as LogLineContract, type LogMaintenancePolicyId, type LogMaintenanceStatusOutput, type LogSnapshotOutput, @@ -23,7 +25,7 @@ import { Heading } from "../ui/Heading.tsx"; import { Icon } from "../ui/Icon.tsx"; import { Input } from "../ui/Input.tsx"; import { LoadingState } from "../ui/LoadingState.tsx"; -import { Modal } from "../ui/Modal.tsx"; +import { PageState } from "../ui/PageState.tsx"; import { Select } from "../ui/Select.tsx"; import { Text } from "../ui/Text.tsx"; import { Virtualizer } from "../ui/Virtualizer.tsx"; @@ -36,26 +38,40 @@ import { } from "./logLevelFiltering.ts"; import { LogLine } from "./LogLine.tsx"; import { presentRedactedLogLine } from "./logLinePresentation.ts"; -import { logFailureMessage, logSourceGroupLabel } from "./logPresentation.ts"; +import { LogMaintenancePanel } from "./LogMaintenancePanel.tsx"; +import { logSourceGroupLabel } from "./logPresentation.ts"; +import { logSnapshotRowOptions } from "./logQueries.ts"; export interface LogsViewProps { readonly maintenance?: LogMaintenanceStatusOutput; readonly maintenanceError?: string; readonly maintenanceLoading?: boolean; + readonly maintenanceRefreshing?: boolean; readonly onClearSearch: () => void; readonly onRefresh: () => void; + readonly onRefreshMaintenance: () => void; readonly onRequestMaintenance: ( - policyId: LogMaintenancePolicyId + policyId: LogMaintenancePolicyId, + dryRun: boolean ) => Promise; readonly onSearch: (query: string) => void; readonly onSelectSource: (sourceId: string) => void; + readonly onRowCountChange: (rowCount: number) => void; readonly refreshing?: boolean; + readonly requestedRun?: JobRunDetail; + readonly requestedRunError?: string; + readonly requestedRunInactiveConfirmed?: boolean; + readonly requestedRunLoading?: boolean; + readonly requestedRunRequest?: RequestLogMaintenanceOutput; + readonly rowCount: number; readonly searchQuery?: string; readonly selectedSourceId?: string; readonly snapshot?: LogSnapshotOutput; readonly snapshotError?: string; readonly snapshotLoading?: boolean; readonly sources: readonly LogSource[]; + readonly sourcesError?: string; + readonly sourcesLoading?: boolean; } function sourceDescription(source: LogSource): string { @@ -70,15 +86,41 @@ function sourceDescription(source: LogSource): string { : `${group} · ${formatByteCount(source.sizeBytes)}`; } +function downloadRedactedLogLines( + snapshot: LogSnapshotOutput, + lines: readonly LogLineContract[] +): void { + const blob = new Blob([lines.map(({ line }) => line).join("\n")], { + type: "text/plain;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.download = `mira-dashboard-${snapshot.sourceId}-${snapshot.revision.slice(0, 12)}.log`; + anchor.hidden = true; + anchor.href = url; + document.body.append(anchor); + try { + anchor.click(); + } finally { + anchor.remove(); + URL.revokeObjectURL(url); + } +} + function LogSnapshot({ activeLevels, + rowCount, searchQuery, snapshot, }: Readonly<{ readonly activeLevels: ReadonlySet; + readonly rowCount: number; readonly searchQuery?: string; readonly snapshot: LogSnapshotOutput; }>) { + const [clearedLineIds, setClearedLineIds] = useState>( + () => new Set() + ); const presentedLines = snapshot.lines.map((entry) => ({ entry, presentation: presentRedactedLogLine(entry, { @@ -86,13 +128,16 @@ function LogSnapshot({ sourceId: snapshot.sourceId, }), })); - const visibleLines = presentedLines.filter(({ presentation }) => + const unclearedLines = presentedLines.filter( + ({ entry }) => !clearedLineIds.has(entry.id) + ); + const visibleLines = unclearedLines.filter(({ presentation }) => logLevelIsVisible(presentation.level, activeLevels) ); const activeLevelKey = filterableLogLevels .filter((level) => activeLevels.has(level)) .join(","); - const scopeKey = `${snapshot.sourceId}:${searchQuery ?? "latest"}:${activeLevelKey}`; + const scopeKey = `${snapshot.sourceId}:${searchQuery ?? "latest"}:${rowCount}:${activeLevelKey}`; const visibleLineCount = visibleLines.length; const totalLineCount = snapshot.lines.length; const lineCountLabel = @@ -111,6 +156,15 @@ function LogSnapshot({ title="No matching log lines" /> ); + } else if (unclearedLines.length === 0) { + linesContent = ( + + ); } else if (visibleLines.length === 0) { linesContent = ( -
- {formatDashboardDateTime(snapshot.observedAtMs)} - {formatByteCount(snapshot.scannedBytes)} read - {lineCountLabel} - {snapshot.hasEarlier && Older lines not shown} +
+
+ {formatDashboardDateTime(snapshot.observedAtMs)} + {formatByteCount(snapshot.scannedBytes)} read + {lineCountLabel} + {snapshot.hasEarlier && Older lines not shown} +
+ +
{linesContent} @@ -209,53 +290,40 @@ export function LogsView({ maintenance, maintenanceError, maintenanceLoading = false, + maintenanceRefreshing = false, onClearSearch, onRefresh, + onRefreshMaintenance, onRequestMaintenance, + onRowCountChange, onSearch, onSelectSource, refreshing = false, + requestedRun, + requestedRunError, + requestedRunInactiveConfirmed = false, + requestedRunLoading = false, + requestedRunRequest, + rowCount, searchQuery, selectedSourceId, snapshot, snapshotError, snapshotLoading = false, sources, + sourcesError, + sourcesLoading = false, }: LogsViewProps) { - const [actionError, setActionError] = useState(); - const [actionStatus, setActionStatus] = useState(); - const [confirmPolicyId, setConfirmPolicyId] = useState(); - const [runningPolicyId, setRunningPolicyId] = useState(); const [searchDraft, setSearchDraft] = useState(searchQuery ?? ""); const [activeLevels, setActiveLevels] = useState>(allLogLevels); const selectedSource = sources.find(({ id }) => id === selectedSourceId); - const confirmedPolicy = maintenance?.policies.find( - ({ id }) => id === confirmPolicyId - ); function submitSearch() { const query = searchDraft.trim(); if (query.length > 0) onSearch(query); } - async function runMaintenance(policyId: LogMaintenancePolicyId) { - setActionError(undefined); - setActionStatus(undefined); - setRunningPolicyId(policyId); - try { - const result = await onRequestMaintenance(policyId); - setConfirmPolicyId(undefined); - setActionStatus( - `${confirmedPolicy?.label ?? "Log maintenance"} was added to the queue as job ${result.jobRunId}.` - ); - } catch (error) { - setActionError(logFailureMessage(error)); - } finally { - setRunningPolicyId(undefined); - } - } - let snapshotContent: ReactNode; if (snapshotLoading && snapshot === undefined) { snapshotContent = ; @@ -269,23 +337,75 @@ export function LogsView({ snapshotContent = ( ); } + const maintenancePanel = ( + + ); if (sources.length === 0) { + let sourceState: ReactNode; + if (sourcesLoading) { + sourceState = ; + } else if (sourcesError === undefined) { + sourceState = ( + + + Refresh sources + + } + description="Add a log source to the Dashboard configuration to view it here." + title="No log sources" + /> + ); + } else { + sourceState = ( + + ); + } return ( - +
+ {sourceState} + {maintenancePanel} +
); } return (
+
@@ -297,16 +417,37 @@ export function LogsView({ sensitive values are removed before lines reach this page.
- +
+
+